feat(doc): add v2 API support for +create/+fetch/+update

Introduce an OpenAPI-backed v2 path for docs shortcuts alongside the
existing MCP-backed v1 path, selectable via --api-version v1|v2. v1
stays default and prints a deprecation notice; v2 is auto-detected
when v2-only flags are present.

v2 highlights:
- +create / +update take --content (XML or Markdown via --doc-format);
  +update adds structured --command ops (str_replace, block_*, append,
  overwrite, ...).
- +fetch adds --detail (simple/with-ids/full), partial read --scope
  (outline/range/keyword/section) with context + max-depth controls.
- XML output is no longer HTML-escaped (OutRaw / OutFormatRaw).
- Versioned --help hides flags that don't apply to the selected API
  version via a new Shortcut.PostMount hook.

Docs/skills updated to v2 usage; block-id based comments replace
--selection-with-ellipsis in drive +add-comment references.

Change-Id: I4b3c35fe967920f9e2546d5b6ca0ec7dcfd415fc
This commit is contained in:
sunpeiyang.996
2026-04-20 22:19:40 +08:00
committed by chenjinxiong.03
parent d25f79bb64
commit 9229c50fcf
31 changed files with 1995 additions and 1374 deletions

View File

@@ -201,7 +201,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
lark-cli docs +create --doc-format markdown --content "<title>Weekly Report</title>\n# Progress\n- Completed feature X"
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -202,7 +202,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
lark-cli docs +create --doc-format markdown --content "<title>周报</title>\n# 本周进展\n- 完成了 X 功能"
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -488,12 +488,46 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
fmt.Fprintln(ctx.IO().Out, string(b))
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
if ctx.JqExpr != "" {
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
if ctx.outputErr == nil {
ctx.outputErr = err
}
}
return
}
enc := json.NewEncoder(ctx.IO().Out)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
_ = enc.Encode(env)
}
// OutFormat prints output based on --format flag.
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
// When JqExpr is set, routes through Out() regardless of format.
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.outFormat(data, meta, prettyFn, false)
}
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
// Use this when the data contains XML/HTML content that should be preserved as-is.
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.outFormat(data, meta, prettyFn, true)
}
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
outFn := ctx.Out
if raw {
outFn = ctx.OutRaw
}
if ctx.JqExpr != "" {
ctx.Out(data, meta)
outFn(data, meta)
return
}
switch ctx.Format {
@@ -501,10 +535,10 @@ func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, pretty
if prettyFn != nil {
prettyFn(ctx.IO().Out)
} else {
ctx.Out(data, meta)
outFn(data, meta)
}
case "json", "":
ctx.Out(data, meta)
outFn(data, meta)
default:
// table, csv, ndjson — pass data directly; FormatValue handles both
// plain arrays and maps with array fields (e.g. {"members":[…]})
@@ -595,6 +629,9 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
registerShortcutFlags(cmd, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
parent.AddCommand(cmd)
if shortcut.PostMount != nil {
shortcut.PostMount(cmd)
}
}
// runShortcut is the execution pipeline for a declarative shortcut.

View File

@@ -3,7 +3,11 @@
package common
import "context"
import (
"context"
"github.com/spf13/cobra"
)
// Flag.Input source constants.
const (
@@ -43,6 +47,11 @@ type Shortcut struct {
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// PostMount is an optional hook called after the cobra.Command is fully
// configured (flags registered, tips set) but before it is added to the
// parent. Use it to install custom help functions or tweak the command.
PostMount func(cmd *cobra.Command)
}
// ScopesForIdentity returns the scopes applicable for the given identity.

View File

@@ -7,9 +7,35 @@ import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
func v1CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title", Hidden: true},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
}
}
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Create(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("content") != "" ||
runtime.Str("parent-token") != "" ||
runtime.Str("parent-position") != ""
}
var DocsCreate = common.Shortcut{
Service: "docs",
Command: "+create",
@@ -17,56 +43,85 @@ var DocsCreate = common.Shortcut{
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)"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
},
v1CreateFlags(),
v2CreateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
count := 0
if runtime.Str("folder-token") != "" {
count++
if useV2Create(runtime) {
return validateCreateV2(ctx, runtime)
}
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
return validateCreateV1(ctx, runtime)
},
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.")
if useV2Create(runtime) {
return dryRunCreateV2(ctx, runtime)
}
return d
return dryRunCreateV1(ctx, runtime)
},
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
if useV2Create(runtime) {
return executeCreateV2(ctx, runtime)
}
augmentDocsCreateResult(runtime, result)
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
return executeCreateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
},
}
func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} {
// ── V1 (MCP) implementation ──
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("markdown") == "" {
return common.FlagErrorf("--markdown is required")
}
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
}
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildCreateArgsV1(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
}
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
}
augmentCreateResultV1(runtime, result)
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
}
@@ -90,18 +145,17 @@ type docsPermissionTarget struct {
Type string
}
func augmentDocsCreateResult(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectDocsPermissionTarget(result)
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectPermissionTarget(result)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
}
func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parseDocsPermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
return ref
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID != "" {
return docsPermissionTarget{Token: docID, Type: "docx"}
@@ -109,16 +163,14 @@ func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTar
return docsPermissionTarget{}
}
func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
if strings.TrimSpace(docURL) == "" {
return docsPermissionTarget{}, false
}
ref, err := parseDocumentRef(docURL)
if err != nil {
return docsPermissionTarget{}, false
}
switch ref.Kind {
case "wiki":
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
@@ -128,3 +180,68 @@ func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool
return docsPermissionTarget{}, false
}
}
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
// whiteboard creation markdown is detected.
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
}
}
// ── Shared helpers ──
// concatFlags combines multiple flag slices into one.
func concatFlags(slices ...[]common.Flag) []common.Flag {
var out []common.Flag
for _, s := range slices {
out = append(out, s...)
}
return out
}
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
m := make(map[string]string, len(v1)+len(v2))
for _, f := range v1 {
m[f.Name] = "v1"
}
for _, f := range v2 {
m[f.Name] = "v2"
}
return m
}

View File

@@ -9,15 +9,182 @@ import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
// ── V2 (OpenAPI) tests ──
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>项目计划</title><h1>目标</h1>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
}
// ── V1 (MCP) tests ──
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
@@ -59,77 +226,9 @@ func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
@@ -164,12 +263,6 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
@@ -180,6 +273,8 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
}
}
// ── Helpers ──
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
@@ -193,6 +288,18 @@ func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
}
}
func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
payload, _ := json.Marshal(result)
reg.Register(&httpmock.Stub{
@@ -214,15 +321,7 @@ func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interfa
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "docs"}
DocsCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
return mountAndRunDocs(t, DocsCreate, args, f, stdout)
}
func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
}
}
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("content") == "" {
return common.FlagErrorf("--content is required")
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateBody(runtime)
d := common.NewDryRunAPI().
POST("/open-apis/docs_ai/v1/documents").
Desc("OpenAPI: create document").
Body(body)
if runtime.IsBot() {
d.Desc("After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body := buildCreateBody(runtime)
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
return err
}
stripBlockIDs(data)
augmentDocsCreatePermission(runtime, data)
runtime.OutRaw(data, nil)
return nil
}
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"content": runtime.Str("content"),
}
if v := runtime.Str("parent-token"); v != "" {
body["parent_token"] = v
}
if v := runtime.Str("parent-position"); v != "" {
body["parent_position"] = v
}
return body
}
// augmentDocsCreatePermission grants full_access to the current CLI user when
// the document was created with bot identity.
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
docID := strings.TrimSpace(common.GetString(doc, "document_id"))
if docID == "" {
return
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, docID, "docx"); grant != nil {
data["permission_grant"] = grant
}
}

View File

@@ -9,9 +9,45 @@ import (
"io"
"strconv"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
func v1FetchFlags() []common.Flag {
return []common.Flag{
{Name: "offset", Desc: "pagination offset", Hidden: true},
{Name: "limit", Desc: "pagination limit", Hidden: true},
}
}
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only
// flags with non-default values (bare "--doc xxx" stays on v1).
func useV2Fetch(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
// --doc-format default is "xml", --detail default is "simple", --revision-id default is -1.
// Only trigger auto-detect when a non-default value is present.
if d := runtime.Str("detail"); d != "" && d != "simple" {
return true
}
if f := runtime.Str("doc-format"); f != "" && f != "xml" {
return true
}
if runtime.Int("revision-id") != -1 {
return true
}
if m := runtime.Str("scope"); m != "" && m != "full" {
return true
}
return false
}
var DocsFetch = common.Shortcut{
Service: "docs",
Command: "+fetch",
@@ -20,66 +56,81 @@ var DocsFetch = common.Shortcut{
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"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1FetchFlags(),
v2FetchFlags(),
),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
if useV2Fetch(runtime) {
return dryRunFetchV2(ctx, runtime)
}
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)
return dryRunFetchV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
if useV2Fetch(runtime) {
return executeFetchV2(ctx, runtime)
}
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
return executeFetchV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
},
}
// ── V1 (MCP) implementation ──
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildFetchArgsV1(runtime)
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)
}
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+fetch")
args := buildFetchArgsV1(runtime)
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
}
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
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 args
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
}
}
func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
}
body := buildFetchBody(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
return common.NewDryRunAPI().
POST(apiPath).
Desc("OpenAPI: fetch document").
Body(body).
Set("document_id", ref.Token)
}
func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
if err := validateFetchDetail(runtime); err != nil {
return err
}
if err := validateReadModeFlags(runtime); err != nil {
return err
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
body := buildFetchBody(runtime)
data, err := doDocAPI(runtime, "POST", apiPath, body)
if err != nil {
return err
}
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
if content, ok := doc["content"].(string); ok {
fmt.Fprintln(w, content)
}
}
})
return nil
}
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
}
detail := runtime.Str("detail")
switch detail {
case "", "simple":
body["export_option"] = map[string]interface{}{
"export_block_id": false,
"export_style_attrs": false,
"export_cite_extra_data": false,
}
case "with-ids":
body["export_option"] = map[string]interface{}{
"export_block_id": true,
}
case "full":
body["export_option"] = map[string]interface{}{
"export_block_id": true,
"export_style_attrs": true,
"export_cite_extra_data": true,
}
}
if ro := buildReadOption(runtime); ro != nil {
body["read_option"] = ro
}
return body
}
// buildReadOption 拼装 read_option JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
ro := map[string]interface{}{"read_mode": mode}
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
ro["start_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
ro["end_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("keyword")); v != "" {
ro["keyword"] = v
}
if v := runtime.Int("context-before"); v > 0 {
ro["context_before"] = strconv.Itoa(v)
}
if v := runtime.Int("context-after"); v > 0 {
ro["context_after"] = strconv.Itoa(v)
}
if v := runtime.Int("max-depth"); v >= 0 {
ro["max_depth"] = strconv.Itoa(v)
}
return ro
}
// validateFetchDetail 非 xml 格式markdown/text不承载 block_id 与样式属性,拒绝 with-ids/full。
func validateFetchDetail(runtime *common.RuntimeContext) error {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))
if format == "" || format == "xml" {
return nil
}
if detail == "with-ids" || detail == "full" {
return fmt.Errorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
}
return nil
}
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
func validateReadModeFlags(runtime *common.RuntimeContext) error {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
if v := runtime.Int("context-before"); v < 0 {
return fmt.Errorf("--context-before must be >= 0, got %d", v)
}
if v := runtime.Int("context-after"); v < 0 {
return fmt.Errorf("--context-after must be >= 0, got %d", v)
}
if v := runtime.Int("max-depth"); v < -1 {
return fmt.Errorf("--max-depth must be >= -1, got %d", v)
}
switch mode {
case "outline":
return nil
case "range":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
return fmt.Errorf("range mode requires --start-block-id or --end-block-id")
}
return nil
case "keyword":
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return fmt.Errorf("keyword mode requires --keyword")
}
return nil
case "section":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
return fmt.Errorf("section mode requires --start-block-id")
}
return nil
default:
return fmt.Errorf("invalid --scope %q", mode)
}
}

View File

@@ -5,12 +5,13 @@ package doc
import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
var validModes = map[string]bool{
var validModesV1 = map[string]bool{
"append": true,
"overwrite": true,
"replace_range": true,
@@ -20,7 +21,7 @@ var validModes = map[string]bool{
"delete_range": true,
}
var needsSelection = map[string]bool{
var needsSelectionV1 = map[string]bool{
"replace_range": true,
"replace_all": true,
"insert_before": true,
@@ -28,6 +29,32 @@ var needsSelection = map[string]bool{
"delete_range": true,
}
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
func v1UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
{Name: "new-title", Desc: "also update document title", Hidden: true},
}
}
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Update(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("command") != "" ||
runtime.Str("content") != "" ||
runtime.Str("pattern") != "" ||
runtime.Str("block-id") != "" ||
runtime.Str("src-block-ids") != ""
}
var DocsUpdate = common.Shortcut{
Service: "docs",
Command: "+update",
@@ -35,124 +62,104 @@ var DocsUpdate = common.Shortcut{
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"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1UpdateFlags(),
v2UpdateFlags(),
),
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 useV2Update(runtime) {
return validateUpdateV2(ctx, runtime)
}
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)
}
return nil
return validateUpdateV1(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
if useV2Update(runtime) {
return dryRunUpdateV2(ctx, runtime)
}
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)
return dryRunUpdateV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
if useV2Update(runtime) {
return executeUpdateV2(ctx, runtime)
}
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
}
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
}
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
return executeUpdateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
},
}
func normalizeDocsUpdateResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
// ── V1 (MCP) implementation ──
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
mode := runtime.Str("mode")
if mode == "" {
return common.FlagErrorf("--mode is required")
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
if !validModesV1[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 needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
}
return nil
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildUpdateArgsV1(runtime)
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)
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+update")
args := buildUpdateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
}
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
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 args
}

View File

@@ -7,6 +7,32 @@ import (
"testing"
)
// ── V2 tests ──
func TestValidCommandsV2(t *testing.T) {
expected := map[string]bool{
"str_replace": true,
"str_delete": true,
"block_delete": true,
"block_insert_after": true,
"block_copy_insert_after": true,
"block_replace": true,
"block_move_after": true,
"overwrite": true,
"append": true,
}
if len(validCommandsV2) != len(expected) {
t.Fatalf("expected %d commands, got %d", len(expected), len(validCommandsV2))
}
for cmd := range validCommandsV2 {
if !expected[cmd] {
t.Fatalf("unexpected command %q in validCommandsV2", cmd)
}
}
}
// ── V1 tests ──
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
t.Run("blank whiteboard tags", func(t *testing.T) {
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
@@ -30,13 +56,13 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
})
}
func TestNormalizeDocsUpdateResult(t *testing.T) {
func TestNormalizeWhiteboardResult(t *testing.T) {
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
result := map[string]interface{}{
"success": true,
}
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
got, ok := result["board_tokens"].([]string)
if !ok {
@@ -52,7 +78,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
"board_tokens": []interface{}{"board_1", "board_2"},
}
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
want := []string{"board_1", "board_2"}
got, ok := result["board_tokens"].([]string)
@@ -69,7 +95,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
"success": true,
}
normalizeDocsUpdateResult(result, "## plain text")
normalizeWhiteboardResult(result, "## plain text")
if _, ok := result["board_tokens"]; ok {
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
var validCommandsV2 = map[string]bool{
"str_replace": true,
"str_delete": true,
"block_delete": true,
"block_insert_after": true,
"block_copy_insert_after": true,
"block_replace": true,
"block_move_after": true,
"overwrite": true,
"append": true,
}
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "command", Desc: "operation: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "regex pattern for str_replace / str_delete", Hidden: true},
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
}
}
func validCommandsV2Keys() []string {
return []string{"str_replace", "str_delete", "block_delete", "block_insert_after", "block_copy_insert_after", "block_replace", "block_move_after", "overwrite", "append"}
}
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
cmd := runtime.Str("command")
if cmd == "" {
return common.FlagErrorf("--command is required")
}
if !validCommandsV2[cmd] {
return common.FlagErrorf("invalid --command %q, valid: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
}
content := runtime.Str("content")
pattern := runtime.Str("pattern")
blockID := runtime.Str("block-id")
srcBlockIDs := runtime.Str("src-block-ids")
switch cmd {
case "str_replace":
if pattern == "" {
return common.FlagErrorf("--command str_replace requires --pattern")
}
if content == "" {
return common.FlagErrorf("--command str_replace requires --content")
}
case "str_delete":
if pattern == "" {
return common.FlagErrorf("--command str_delete requires --pattern")
}
case "block_delete":
if blockID == "" {
return common.FlagErrorf("--command block_delete requires --block-id")
}
case "block_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_insert_after requires --block-id")
}
if content == "" {
return common.FlagErrorf("--command block_insert_after requires --content")
}
case "block_copy_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
}
if srcBlockIDs == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
}
case "block_move_after":
if blockID == "" {
return common.FlagErrorf("--command block_move_after requires --block-id")
}
if content == "" && srcBlockIDs == "" {
return common.FlagErrorf("--command block_move_after requires --content or --src-block-ids")
}
case "block_replace":
if blockID == "" {
return common.FlagErrorf("--command block_replace requires --block-id")
}
if content == "" {
return common.FlagErrorf("--command block_replace requires --content")
}
case "overwrite":
if content == "" {
return common.FlagErrorf("--command overwrite requires --content")
}
case "append":
if content == "" {
return common.FlagErrorf("--command append requires --content")
}
}
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
}
body := buildUpdateBody(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
Desc("OpenAPI: update document").
Body(body).
Set("document_id", ref.Token)
}
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body := buildUpdateBody(runtime)
data, err := doDocAPI(runtime, "PUT", apiPath, body)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
}
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
cmd := runtime.Str("command")
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
blockID := runtime.Str("block-id")
if cmd == "append" {
cmd = "block_insert_after"
blockID = "-1"
}
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"command": cmd,
}
if v := runtime.Int("revision-id"); v != 0 {
body["revision_id"] = v
}
if v := runtime.Str("content"); v != "" {
body["content"] = v
}
if v := runtime.Str("pattern"); v != "" {
body["pattern"] = v
}
if blockID != "" {
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {
body["src_block_ids"] = v
}
return body
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type documentRef struct {
@@ -56,6 +57,26 @@ func extractDocumentToken(raw, marker string) (string, bool) {
return token, true
}
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
return runtime.DoAPIJSON(method, apiPath, nil, body)
}
// stripBlockIDs removes "block_id" from each entry in data.document.newblocks.
func stripBlockIDs(data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
blocks, _ := doc["newblocks"].([]interface{})
for _, b := range blocks {
if m, ok := b.(map[string]interface{}); ok {
delete(m, "block_id")
}
}
}
func buildDriveRouteExtra(docID string) (string, error) {
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
if err != nil {

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/shortcuts/common"
)
// installVersionedHelp sets a custom help function on cmd that shows only the
// flags relevant to the selected --api-version. flagVersions maps flag name to
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
// always visible.
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
origHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
ver, _ := cmd.Flags().GetString("api-version")
if ver == "" {
ver = defaultVersion
}
// Show/hide flags based on the active version.
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if fv, ok := flagVersions[f.Name]; ok {
f.Hidden = fv != ver
}
})
origHelp(cmd, args)
if ver == "v1" {
fmt.Fprintf(cmd.OutOrStdout(),
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
" Use --api-version v2 for the latest API:\n"+
" %s %s --api-version v2 --help\n"+
" Upgrade skill:\n"+
" npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
cmd.Parent().Name(), cmd.Name())
}
})
}
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
// path is used, guiding users to upgrade their skill to v2.
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
fmt.Fprintf(runtime.IO().ErrOut,
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n"+
"Please upgrade your skill: npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
shortcut)
}

View File

@@ -102,8 +102,8 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--selection-with-ellipsis` 或 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--selection-with-ellipsis` / `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | `file_token` | 同添加评论 |
@@ -111,8 +111,8 @@ Drive Folder (云空间文件夹)
### 评论能力边界(关键!)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--selection-with-ellipsis` / `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--selection-with-ellipsis` 或 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --detail with-ids` 获取。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。

View File

@@ -1,127 +1,55 @@
---
name: lark-doc
version: 1.0.0
description: "飞书云文档:创建和编辑飞书文档。 Markdown 创建文档、获取文档内容、更新文档(追加/覆盖/替换/插入/删除)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
version: 2.0.0
description: "飞书云文档:创建和编辑飞书文档。默认使用 DocxXML 格式(也支持 Markdown)。创建文档、获取文档内容(支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文、更新文档八种指令str_replace/str_delete/block_insert_after/block_replace/block_delete/block_move_after/overwrite/append)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help"
---
# docs (v1)
# docs (v2)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 核心概念
### 文档类型与 Token
飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`
### 文档 URL 格式与 Token 处理
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|----------|---------------------------------------------------------|-----------|----------|
| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
### Wiki 链接特殊处理(关键!)
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。
#### 处理流程
1. **使用 `wiki.spaces.get_node` 查询节点信息**
```bash
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
```
2. **从返回结果中提取关键信息**
- `node.obj_type`文档类型docx/doc/sheet/bitable/slides/file/mindnote
- `node.obj_token`**真实的文档 token**(用于后续操作)
- `node.title`:文档标题
3. **根据 `obj_type` 使用对应的 API**
| obj_type | 说明 | 使用的 API |
|----------|------|-----------|
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
| `doc` | 旧版云文档 | `drive file.comments.*` |
| `sheet` | 电子表格 | `sheets.*` |
| `bitable` | 多维表格 | `bitable.*` |
| `slides` | 幻灯片 | `drive.*` |
| `file` | 文件 | `drive.*` |
| `mindnote` | 思维导图 | `drive.*` |
#### 查询示例
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create`、`docs +fetch`、`docs +update` 命令必须携带 `--api-version v2`。**
```bash
# 查询 wiki 节点
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
# 常用示例
lark-cli docs +fetch --api-version v2 --doc "文档URL或token"
lark-cli docs +create --api-version v2 --content '<title>标题</title><p>内容</p>'
lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '<p>内容</p>'
```
返回结果示例:
```json
{
"node": {
"obj_type": "docx",
"obj_token": "xxxx",
"title": "标题",
"node_type": "origin",
"space_id": "12345678910"
}
}
```
## 前置条件 — 执行操作前必读
### 资源关系
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
```
Wiki Space (知识空间)
└── Wiki Node (知识库节点)
├── obj_type: docx (新版文档)
│ └── obj_token (真实文档 token)
├── obj_type: doc (旧版文档)
│ └── obj_token (真实文档 token)
├── obj_type: sheet (电子表格)
│ └── obj_token (真实文档 token)
├── obj_type: bitable (多维表格)
│ └── obj_token (真实文档 token)
└── obj_type: file/slides/mindnote
└── obj_token (真实文档 token)
**未读完以上文件就执行相应操作会导致参数选择错误、格式错误或样式不达标。**
Drive Folder (云空间文件夹)
└── File (文件/文档)
└── file_token (直接使用)
```
## 绘图需求识别与挖掘
用户很少主动提"画板"——**默认**使用飞书画板承载图表,命中以下任一信号即触发:
- 用户提到图表类型:架构图、流程图、时序图、组织图、路线图、对比图、鱼骨图、飞轮图、思维导图等
- 用户表达可视化意图:画一下、梳理关系、画个流程、给我一个图、方便汇报等
- 文档主题涉及结构关系、流程走向、时间线、数据对比
以下场景不加图:用户明确拒绝、合同/法律条款/合规声明等严谨连续文本、原样转录任务。
> [!CAUTION]
> 命中后,**MUST** 先读取 [`references/lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 并**严格按其流程执行**。
>
> **绝对禁止**用 `whiteboard-cli` 渲染 PNG 后通过 `docs +media-insert` 插入文档——图表必须通过 `lark-cli whiteboard +update` 写入画板 block这是唯一合法路径。
> **格式选择规则(全局):** `docs +create` 和 `docs +update` 始终使用 XML 格式(`--doc-format xml`,即默认值),除非用户明确要求使用 Markdown。XML 支持 callout、grid、checkbox 等丰富 block 类型——不要因为 Markdown 更简单就自行切换。
## 快速决策
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。
- 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。
- `docs +search` 不是只搜文档 / Wiki结果里会直接返回 `SHEET` 等云空间对象。
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户说"找一个表格""按名称搜电子表格""找报表""最近打开的表格" → 先用 `lark-cli docs +search` 做资源发现
- `docs +search` 不只搜文档/Wiki结果里会直接返回 `SHEET` 等云空间对象
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
- 文档内容中出现嵌入的 `<sheet>``<bitable>``<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
## 补充说明
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
| 标签 / 属性 | 提取字段 | 切到技能 |
|-|-|-|
| `<sheet token="..." sheet-id="...">` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
**补充:** `docs +search` 也承担"先定位云空间对象,再切回对应业务 skill 操作"的资源发现入口角色;当用户口头说"表格/报表"时,也优先从这里开始。
## Shortcuts推荐优先使用
@@ -130,9 +58,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-doc-search.md) | Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search) |
| [`+create`](references/lark-doc-create.md) | Create a Lark document |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content |
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |

View File

@@ -1,672 +1,89 @@
# docs +create创建飞书云文档
> **前置条件** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证、全局参数和安全规则
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 3. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
> 4. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
Lark-flavored Markdown 内容创建一个新的飞书云文档。
XML默认 Markdown 内容创建一个新的飞书云文档。
## 重要说明
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
> **⚠️ 格式选择规则:始终使用 XML 格式(默认),除非用户明确要求使用 Markdown。** XML 表达能力更强、支持更多 block 类型callout、grid、checkbox 等),是推荐的首选格式。不要因为 Markdown 写起来更简单就自行切换为 Markdown。
## 命令
```bash
# 创建简单文档
lark-cli docs +create --title "项目计划" --markdown "## 目标\n\n- 目标 1\n- 目标 2"
# 创建 XML 文档(默认格式,推荐)
lark-cli docs +create --api-version v2 --content '<title>项目计划</title><h1>目标</h1><ul><li>目标 1</li><li>目标 2</li></ul>'
# 创建到指定文件夹
lark-cli docs +create --title "会议纪要" --folder-token fldcnXXXX --markdown "## 讨论议题\n\n1. 进度\n2. 计划"
# 创建到指定文件夹XML
lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<title>标题</title><p>首段内容</p>'
# 创建到知识库节点下
lark-cli docs +create --title "技术文档" --wiki-node wikcnXXXX --markdown "## API 说明"
# 创建到个人知识库XML
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
# 创建到知识空间根目录
lark-cli docs +create --title "概览" --wiki-space 7000000000000000000 --markdown "## 项目概览"
# 创建到个人知识库
lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown "## 笔记"
# 仅当用户明确要求时才使用 Markdown
lark-cli docs +create --api-version v2 --doc-format markdown --content "# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2"
```
## 返回值
工具成功执行后,返回一个 JSON 对象,包含以下字段:
```json
{
"ok": true,
"identity": "user",
"data": {
"document": {
"document_id": "doxcnXXXXXXXXXXXXXXXXXXX",
"revision_id": 1,
"url": "https://xxx.feishu.cn/docx/doxcnXXXXXXXXXXXXXXXXXXX",
"newblocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "token": "boardXXXX" }
]
}
}
}
```
- **`doc_id`**string文档的唯一标识符token格式如 `doxcnXXXXXXXXXXXXXXXXXXX`
- **`doc_url`**string文档的访问链接可直接在浏览器中打开
- **`message`**string操作结果消息如"文档创建成功"
- **`permission_grant`**object可选`--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
- **`document.newblocks`**:本次操作新增的 block 列表(如画板),可从中提取 `token` 用于后续编辑
> [!IMPORTANT]
> 当文档创建在 `wiki_node` 或 `wiki_space` 下时,返回的 `doc_url` 可能是 `/wiki/...` 形式的知识库链接,而不是 `/docx/...` 形式的文档链接
> 如果后续要调用 [`lark-doc-media-insert`](lark-doc-media-insert.md) 这类当前只支持 `doc_id` 或 `/docx/...` URL 自动提取的 skill请优先使用返回值里的 `doc_id`,不要直接复用这个 `doc_url`。
> [!IMPORTANT]
> 如果文档是**以应用身份bot创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后, CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。
> \[!IMPORTANT]
> 如果文档是**以应用身份bot创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**
>
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
> - `status = granted`:当前 CLI 用户已获得该文档的可管理权限
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份bot授予当前用户权限
> - `status = failed`:文档已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文档
>
> `permission_grant.perm = full_access` 表示该资源已授予可管理权限”。
> `permission_grant.perm = full_access` 表示该资源已授予可管理权限”。
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
## 重要:创建文档后的可视化流程
如果文档中包含空白画板(`<whiteboard type="blank"></whiteboard>`**必须继续以下步骤**
1. 从返回值的 `data.board_tokens` 字段记录所有新建画板的 token
2. 读取 `../../lark-whiteboard/SKILL.md`,跳至"渲染 & 写入画板"章节,为每个 board_token 生成并写入实际内容
3. 确认所有画板都有实际内容后,任务才算完成
**仅创建空白画板是不够的!** 如果只创建空白画板而不填充内容,任务将被视为未完成。
> ⚠️ **警告**:务必检查返回值中是否有 `board_tokens` 字段。如果有,说明创建了空白画板,必须继续填充内容!
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--markdown` | 是 | 文档的 Markdown 内容Lark-flavored Markdown 格式) |
| `--title` | | 文档标题 |
| `--folder-token` | 否 | 父文件夹 token`--wiki-node``--wiki-space` 互斥 |
| `--wiki-node` | 否 | 知识库节点 token 或 URL(与 `--folder-token``--wiki-space` 互斥) |
| `--wiki-space` | 否 | 知识空间 ID特殊值 `my_library` 表示个人知识库(与 `--folder-token``--wiki-node` 互斥) |
### markdown必填
文档的 Markdown 内容,使用 Lark-flavored Markdown 格式。
调用本工具的 markdown 内容应当尽量结构清晰,样式丰富,有很高的可读性。合理地使用 callout 高亮块、分栏、表格、图片和空白画板等能力,做到图文并茂。
你需要遵循以下原则:
- **结构清晰**:标题层级 ≤ 4 层,用 Callout 突出关键信息
- **视觉节奏**:用分割线、分栏、表格打破大段纯文字
- **图文交融**:流程、架构或草图需要可视化时,优先使用图片、表格或空白画板
- **克制留白**Callout 不过度、加粗只强调核心词
- **主动画板**:文档涉及架构、流程、组织、时间线、因果等逻辑关系时,**必须**在 markdown 对应章节的文字内容之后插入 `<whiteboard type="blank"></whiteboard>` 占位,每个图表对应一个标签。**禁止**用 `whiteboard-cli` 渲染的 PNG/SVG 图片替代画板。创建完成后从返回值 `data.board_tokens` 取 token读取 `../../lark-whiteboard/SKILL.md` 的"渲染 & 写入画板"章节为每个 token 写入图表内容。例:文档含"系统整体架构""分层架构""部署架构"各需插入一个画板,"类图"也需插入一个画板(走 Mermaid 路由)。
当用户有明确的样式、风格需求时,应当以用户的需求为准!
**重要提示**
- **禁止重复标题**markdown 内容开头不要写与 title 相同的一级标题title 参数已经是文档标题markdown 应直接从正文内容开始
- **目录**:飞书自动生成,无需手动添加
- Markdown 语法必须符合 Lark-flavored Markdown 规范,详见下方"内容格式"章节
- 创建较长的文档时,强烈建议配合 `docs +update --mode append`,进行分段的创建,提高成功率
### folder-token可选
父文件夹的 token。如果不提供文档将创建在用户的个人空间根目录。
folder_token 可以从飞书文件夹 URL 中获取,格式如:`https://xxx.feishu.cn/drive/folder/fldcnXXXX`,其中 `fldcnXXXX` 即为 folder_token。
### wiki-node可选
知识库节点 token 或 URL可选传入则在该节点下创建文档与 folder-token 和 wiki-space 互斥)
wiki_node 可以从飞书知识库页面 URL 中获取,格式如:`https://xxx.feishu.cn/wiki/wikcnXXXX`,其中 `wikcnXXXX` 即为 wiki_node token。
### wiki-space可选
知识空间 ID可选传入则在该空间根目录下创建文档。特殊值 `my_library` 表示用户的个人知识库。与 wiki-node 和 folder-token 互斥)
wiki_space 可以从知识空间设置页面 URL 中获取,格式如:`https://xxx.feishu.cn/wiki/settings/7000000000000000000`,其中 `7000000000000000000` 即为 wiki_space ID。
**参数优先级**wiki-node > wiki-space > folder-token
## 示例
### 示例 1创建简单文档
```bash
lark-cli docs +create --title "项目计划" --markdown "## 项目概述\n\n这是一个新项目。\n\n## 目标\n\n- 目标 1\n- 目标 2"
```
### 示例 2使用飞书扩展语法
```bash
lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" background-color="light-blue">\n重要需求说明\n</callout>'
```
# 内容格式
文档内容使用 **Lark-flavored Markdown** 格式,这是标准 Markdown 的扩展版本,支持飞书文档的所有块类型和富文本格式。
## 通用规则
- 使用标准 Markdown 语法作为基础
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3``version~1.0``final_trajectory` 通常应保持原样,只有像 `*斜体*``**粗体**``~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
---
## 基础块类型
### 文本(段落)
```markdown
普通文本段落
段落中的**粗体文字**
多个段落之间用空行分隔。
居中文本 {align="center"}
右对齐文本 {align="right"}
```
**段落对齐**:支持 `{align="left|center|right"}` 语法。可与颜色组合:`{color="blue" align="center"}`
### 标题
飞书支持 9 级标题。H1-H6 使用标准 Markdown 语法H7-H9 使用 HTML 标签:
```markdown
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
<h7>七级标题</h7>
<h8>八级标题</h8>
<h9>九级标题</h9>
# 带颜色的标题 {color="blue"}
## 红色标题 {color="red"}
# 居中标题 {align="center"}
## 蓝色居中标题 {color="blue" align="center"}
```
**标题属性**:支持 `{color="颜色名"}``{align="left|center|right"}` 语法可组合使用。颜色值red, orange, yellow, green, blue, purple, gray。请谨慎使用该能力。
### 列表
有序列表、无序列表嵌套使用 tab 或者 2 空格缩进:
```markdown
- 无序项1
- 无序项1.a
- 无序项1.b
1. 有序项1
2. 有序项2
- [ ] 待办
- [x] 已完成
```
### 引用块
```markdown
> 这是一段引用
> 可以跨多行
> 引用中支持**加粗**和*斜体*等格式
```
### 代码块
**注意**:只支持围栏代码块(` ``` `),不支持缩进代码块。
````markdown
```python
print("Hello")
```
````
支持语言python, javascript, go, java, sql, json, yaml, shell 等。
### 分割线
```markdown
---
```
---
## 富文本格式
### 文本样式
`**粗体**` `*斜体*` `~~删除线~~` `` `行内代码` `` `<u>下划线</u>`
### 文字颜色
`<text color="red">红色</text>` `<text background-color="yellow">黄色背景</text>`
支持: red, orange, yellow, green, blue, purple, gray
### 链接
`[链接文字](https://example.com)` (不支持锚点链接)
### 行内公式LaTeX
`$E = mc^2$``$`前后需空格)或 `<equation>E = mc^2</equation>`(无限制,推荐)
---
## 高级块类型
### 高亮块Callout
```html
<callout emoji="✅" background-color="light-green" border-color="green">
支持**格式化**的内容,可包含多个块
</callout>
```
**属性**: emoji (使用 emoji 字符如 ✅ ⚠️ 💡), background-color, border-color, text-color
**背景色**: light-red/red, light-blue/blue, light-green/green, light-yellow/yellow, light-orange/orange, light-purple/purple, pale-gray/light-gray/dark-gray
**常用**: 💡light-blue(提示) ⚠light-yellow(警告) ❌light-red(危险) ✅light-green(成功)
**限制**: callout 子块仅支持文本、标题、列表、待办、引用。不支持代码块、表格、图片。
### 分栏Grid
适合对比、并列展示场景。支持 2-5 列:
#### 两栏(等宽)
```html
<grid cols="2">
<column>
左栏内容
</column>
<column>
右栏内容
</column>
</grid>
```
#### 三栏自定义宽度
```html
<grid cols="3">
<column width="20">左栏(20%)</column>
<column width="60">中栏(60%)</column>
<column width="20">右栏(20%)</column>
</grid>
```
**属性**: `cols`(列数 2-5), `width`(列宽百分比,总和为 100等宽时可省略)
### 表格
#### 标准 Markdown 表格
```markdown
| 列 1 | 列 2 | 列 3 |
|------|------|------|
| 单元格 1 | 单元格 2 | 单元格 3 |
| 单元格 4 | 单元格 5 | 单元格 6 |
```
#### 飞书增强表格
当单元格需要复杂内容(列表、代码块、高亮块等)时使用。
**层级结构**(必须严格遵守):
```
<lark-table> <- 表格容器
<lark-tr> <- 行(直接子元素只能是 lark-tr
<lark-td>内容</lark-td> <- 单元格(直接子元素只能是 lark-td
<lark-td>内容</lark-td> <- 每行的 lark-td 数量必须相同!
</lark-tr>
</lark-table>
```
**属性**
- `column-widths`:列宽,逗号分隔像素值,总宽约 730
- `header-row`:首行是否为表头(`"true"` 或 `"false"`
- `header-column`:首列是否为表头(`"true"` 或 `"false"`
**单元格写法**:内容前后必须空行
```html
<lark-td>
这里写内容
</lark-td>
```
**完整示例**2行3列
```html
<lark-table column-widths="200,250,280" header-row="true">
<lark-tr>
<lark-td>
**表头1**
</lark-td>
<lark-td>
**表头2**
</lark-td>
<lark-td>
**表头3**
</lark-td>
</lark-tr>
<lark-tr>
<lark-td>
普通文本
</lark-td>
<lark-td>
- 列表项1
- 列表项2
</lark-td>
<lark-td>
代码内容
</lark-td>
</lark-tr>
</lark-table>
```
**限制**:单元格内不支持 Grid 和嵌套表格
**合并单元格**:读取时返回 `rowspan/colspan` 属性,创建暂不支持
**禁止**
- 混用 Markdown 表格语法(`|---|`
- 使用 `<br/>` 换行
- 遗漏 `<lark-td>` 标签
### 图片
```html
<image url="https://example.com/image.png" width="800" height="600" align="center" caption="图片描述文字"/>
```
**属性**: url (必需,系统会自动下载并上传), width, height, align (left/center/right), caption
**注意**: 不支持直接使用 `token` 属性(如 `<image token="xxx"/>`),只支持 URL 方式。系统会自动下载图片并上传到飞书。
支持 PNG/JPG/GIF/WebP/BMP最大 10MB
**图片/文件插入方式选择**
- **有公开可访问的图片 URL** → 直接在 `docs +create` / `docs +update` 的 markdown 中使用 `<image url="..."/>` 一步到位
- **本地图片或文件** → 先用 `docs +create` / `docs +update` 创建或更新文档文本内容,再用 `lark-doc-media-insert`docs +media-insert将本地图片或文件追加到文档末尾
### 文件
```html
<file url="https://example.com/document.pdf" name="文档.pdf" view-type="1"/>
```
**属性**:
- url (文件 URL必需系统会自动下载并上传)
- name (文件名,必需)
- view-type (1=卡片视图, 2=预览视图,可选)
**注意**: 不支持直接使用 `token` 属性(如 `<file token="xxx"/>`
### 画板
创建空白画板时,直接在 markdown 中写 `<whiteboard type="blank"></whiteboard>`。
自然语言请求示例:
- “帮我创建一个带单个空白画板的文档”
- “帮我创建一个文档,里面放两个空白画板”
```bash
# 创建带单个空白画板的文档
lark-cli docs +create --title "空白画板示例" --markdown '<whiteboard type="blank"></whiteboard>'
```
```html
<whiteboard type="blank"></whiteboard>
```
一次创建多个空白画板时,在同一个 markdown 里重复多个标签:
```html
<whiteboard type="blank"></whiteboard>
<whiteboard type="blank"></whiteboard>
```
#### 读取画板
读取时返回 `<whiteboard>` 标签:
```html
<whiteboard token="xxx" align="center" width="800" height="600"/>
```
**重要说明**
- 创建空白画板时,直接使用 `<whiteboard type="blank"></whiteboard>`
- 读取时只能获取 token可通过 media-download 查看内容,无法直接读出画板内部内容
- 画板编辑:详见 [../../lark-whiteboard/SKILL.md](../../lark-whiteboard/SKILL.md)
### 多维表格Base
```html
<bitable view="table"/>
<bitable view="kanban"/>
```
**属性**: view (table/kanban默认 table)
**注意**: token 是只读属性,创建时不能指定。只能创建空的多维表格,创建后再手动添加数据。
### 会话卡片ChatCard
```html
<chat-card id="oc_xxx" align="center"/>
```
**属性**: id (格式 oc_xxx, 必需), align (left/center/right)
### 内嵌网页Iframe
```html
<iframe url="https://example.com/survey?id=123" type="12"/>
```
**属性**: url (必需), type (组件类型数字, 必需)
**type 枚举**: 1=Bilibili, 2=西瓜, 3=优酷, 4=Airtable, 5=百度地图, 6=高德地图, 8=Figma, 9=墨刀, 10=Canva, 11=CodePen, 12=飞书问卷, 13=金数据
**重要提示**: 仅支持上述列出的网页类型。对于普通网页链接,请使用 Markdown 链接格式 `[链接文字](URL)` 代替。
### 链接预览LinkPreview
```html
<link-preview url="消息链接" type="message"/>
```
目前仅支持消息链接,只支持读取,不支持创建
### 引用容器QuoteContainer
```html
<quote-container>
引用容器内容
</quote-container>
```
与 quote 引用块不同,引用容器是容器类型,可包含多个子块
---
## 高级功能块
### 电子表格Sheet
```html
<sheet rows="5" cols="5"/>
<sheet/>
```
**属性**: rows (行数,默认 3最大 9), cols (列数,默认 3)
**注意**: token 是只读属性,创建时不能指定。只能创建空的电子表格,创建后使用 Sheet API 操作数据。
### 只读块类型
以下块类型仅支持读取,不支持创建:
| 块类型 | 标签 | 说明 |
|--------|------|------|
| 思维笔记 | `<mindnote token="xxx"/>` | 仅获取占位信息 |
| 流程图/UML | `<diagram type="1"/>` | type: 1=流程图, 2=UML |
| AI 模板 | `<ai-template/>` | 无内容占位块 |
### 任务块
```html
<task task-id="xxx" members="ou_123, ou_456" due="2025-01-01">任务标题</task>
```
### 同步块
```html
<!-- 源同步块 -->
<source-synced align="1">子块内容...</source-synced>
<!-- 引用同步块 -->
<reference-synced source-block-id="xxx" source-document-id="yyy">源内容...</reference-synced>
```
### 文档小组件AddOns
```html
<add-ons component-type-id="blk_xxx" record='{"key":"value"}'/>
```
### Wiki 子页面列表SubPageList
```html
<sub-page-list wiki="wiki_xxx"/>
```
仅支持知识库文档创建,需传入当前页面的 wiki token
### 议程Agenda
```html
<agenda>
<agenda-item>
<agenda-title>议程标题</agenda-title>
<agenda-content>议程内容</agenda-content>
</agenda-item>
</agenda>
```
### OKR 系列
```html
<okr id="okr_xxx">
<objective id="obj_1">
<kr id="kr_1"/>
</objective>
</okr>
```
仅支持 user_access_token 创建,需使用 OKR API 进行详细操作
---
## 提及和引用
### 提及用户
```html
<mention-user id="ou_xxx"/>
```
**属性**: id (用户 open_id格式 ou_xxx)
注意不要直接在文档中写 `@张三` 这类格式,应当使用 search-user 获取用户的 id并使用 `mention-user`。
### 提及文档
```html
<mention-doc token="doxcnXXX" type="docx">文档标题</mention-doc>
```
**属性**: token (文档 token), type (docx/sheet/bitable)
---
## 日期和时间
### 日期提醒Reminder
```html
<reminder date="2025-12-31T18:00+08:00" notify="true" user-id="ou_xxx"/>
```
**属性**:
- date (必需): `YYYY-MM-DDTHH:mm+HH:MM`, ISO 8601 带时区偏移
- notify (true/false): 是否发送通知
- user-id (必需): 创建者用户 ID
---
## 数学表达式
### 块级公式LaTeX
````markdown
$$
\int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$
````
### 行内公式
```markdown
爱因斯坦方程:$E = mc^2$(注意 $ 前后需空格,紧邻位置不能有空格)
```
---
## 写作指南
### 场景速查
| 场景 | 推荐组件 | 说明 |
|------|----------|------|
| 重点提示/警告 | Callout | 蓝色提示、黄色警告、红色危险 |
| 对比/并列展示 | Grid 分栏 | 2-3 列最佳,配合 Callout 更醒目 |
| 数据汇总 | 表格 | 简单用 Markdown复杂嵌套用 lark-table |
| 步骤说明 | 有序列表 | 可嵌套子步骤 |
| 时间线/版本 | 有序列表 + 加粗日期 | 适合里程碑、版本记录 |
| 代码展示 | 代码块 | 标注语言,适当添加注释 |
| 知识卡片 | Callout + emoji | 用于概念解释、小贴士 |
| 引用说明 | 引用块 > | 引用原文、名言 |
| 术语对照 | 两列表格 | 中英文、缩写全称等 |
| 架构/流程/组织/时间线/因果 | **空白画板** | 主动插入,用 lark-whiteboard 绘制(用户明确仅文本或数据密集表格场景除外) |
---
| 参数 | 必填 | 说明 |
| ------------------- | -- |---------------------------------------------|
| `--api-version` | 是 | 固定传 `v2` |
| `--content` | | 文档内容XML 或 Markdown 格式) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时 |
| `--parent-token` | 否 | 父文件夹或知识库节点 token`--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
## 最佳实践
- **空行分隔**:不同块类型之间用空行分隔
- **转义字符**:只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3``version~1.0``final_trajectory` 这类普通文本通常不需要转义
- **图片**:使用 URL系统自动下载上传
- **分栏**:列宽总和必须为 100
- **表格选择**:简单数据用 Markdown复杂嵌套用 `<lark-table>`
- **提及**@用户用 `<mention-user>`@文档用 `<mention-doc>`
- **目录**:飞书自动生成,无需手动添加
- 文档标题从内容中自动提取XML `<title>` 或 Markdown `#`),不要在内容开头重复写标题
- 创建较长的文档时,先创建基础内容,再用 `docs +update --command block_insert_after` 分段追加
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档
- [lark-doc-update](lark-doc-update.md) — 更新文档
- [lark-doc-media-insert](lark-doc-media-insert.md) — 插入图片/文件到文档
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -6,110 +6,129 @@
## 命令
```bash
# 获取文档内容(默认输出 Markdown 文本
lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1FjxxxxxxxxxxxxxxxxxxxtnAc"
# 获取文档(默认 XMLsimple
lark-cli docs +fetch --api-version v2 --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
# 直接传 token
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc
# Markdown 格式
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --doc-format markdown
# 知识库 URL 也支持
lark-cli docs +fetch --doc "https://xxx.feishu.cn/wiki/Z1FjxxxxxxxxxxxxxxxxxxxtnAc"
# 带 block ID用于后续 block 级更新)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --detail with-ids
# 分页获取(大文档)
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --offset 0 --limit 50
# 只拿目录
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --scope outline --max-depth 3
# 人类可读格式输出
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
# 按 block id 区间精读
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope range --start-block-id blkA --end-block-id blkB --detail with-ids
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope section --start-block-id <标题id> --detail with-ids
# 按关键词定位(多关键词用 | 分隔,任一命中即返回)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope keyword --keyword "部署|发布|上线"
```
## 选 `--detail`(每块详细度)
| 意图 | `--detail` | 说明 |
|------|-----------|------|
| **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown不含 block ID、样式属性、引用元数据 |
| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID`<p id="blkcnXXXX">`),可用于 `+update``--block-id` |
| **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 |
## 选 `--scope`(读取范围)
`--scope``--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。**
| 模式 | 何时用 | 关键参数 | 行为要点 |
|-|-|-|-|
| `outline` | 不知道结构,先看目录 | `--max-depth`(标题层级上限) | 扁平列出所有标题,**包括嵌在容器里的内嵌标题**(如 callout 里的 h3这些 id 可直接作后续 `section` / `range` 端点 |
| `section` | 读某个标题对应的整节 | `--start-block-id`(必填) | 顶层标题 → 展开到下一同级/更高级标题前;容器内节点(含内嵌标题) → 按"最小包容单元"返回容器/表格切片,不做 heading 扩展;顶层非标题块 → 仅该块 |
| `range` | 已知精确起止 | `--start-block-id` / `--end-block-id` 至少一个;`-1` = 读到末尾 | 两端同顶层 → 顶层序列切片;两端同一容器 → 容器整体;两端同一表格 → 瘦身切片;**跨顶层 → 端点所在顶层块整块输出,不做瘦身** |
| `keyword` | 只有模糊关键词 | `--keyword`(不区分大小写、子串,`\|` 分隔多词 OR | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
**设置 `--scope` 时共用** `--context-before` / `--context-after` / `--max-depth`
- `--max-depth``outline` = 标题层级上限3 = h1~h3其它模式 = 被选块的子树遍历深度(`-1` 不限,`0` 仅块自身)。
- `--context-before/--context-after`**只对整块顶层单元生效**;命中落在容器/表格内(返回容器或切片)时 before/after 被忽略,需要更大范围改用 `section` / `range` 显式指定。
**决策顺序**(核心原则:**局部获取优于全量获取**,能精确到节/区间就绝不全量拉取;**任何文档的第一次读取都应从 `outline` 开始**
1. **第一次接触文档 / 不知道结构** → 先 `outline` 探测目录(**强制首步,无论文档是"目标"还是"引用源"**),再回到 2/3 精读
2. 改/读某个**标题对应的整节** → `section`(最省心,**首选精读方式**
3. 精确自定义起止 / 跨节连续区间 → `range`
4. 只有模糊关键词 → `keyword`
5. **兜底**:确实需要整篇文档时才不传 `--scope`(默认整篇);**不要为了省事就读整篇**,局部模式上下文更省、响应更快
**推荐双步流程**`outline --max-depth 3` 拿目录 → `section --start-block-id <标题id> --detail with-ids` 精读该节。
## 局部读取的输出结构:`<fragment>` 与 `<excerpt>`
设置 `--scope` 时返回的 `content` 被一个 `<fragment>` 节点包裹,属性包含 `mode` / `requested-start` / `requested-end` / `keyword`(按需)。子节点只有两种形态:
- **顶层块**:完整块直接作为 `<fragment>` 的子节点,无额外包裹。
- **`<excerpt top-block-id="..." parent-block-path="...">`**:非顶层节选(容器整体 / 表格瘦身切片)。
- `top-block-id`:所在顶层块 id想看该块全貌时作 `section` / `range` 锚点再拉一次。
- `parent-block-path`:从顶层块到 excerpt 内容直接父节点的 id 路径,`/` 分隔(表格切片时即表格自身 id
**看到 `<excerpt>` 即意味着这是节选**,不能假设看到了该顶层块的全貌。
**表格默认瘦身**:即便 `<table>` 本身是顶层块也只返回 thead + 命中 tr。想拿整张表 → `range --start-block-id <table-id> --end-block-id <table-id>`;切片范围恰好覆盖全部 tr 时 SDK 自动升级为整块、不包 `<excerpt>`
## 返回值
```json
{
"ok": true,
"identity": "user",
"data": {
"document": {
"document_id": "doxcnXXXX",
"revision_id": 12,
"content": "<title>标题</title><p>文档内容...</p>"
}
}
}
```
`content` 的格式由 `--doc-format` 决定。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--doc` | 是 | 文档 URL 或 token支持 `/docx/``/wiki/` 链接,系统自动提取 token |
| `--offset` | | 分页偏移 |
| `--limit` | 否 | 分页大小 |
| `--format` | 否 | 输出格式json默认含 title、markdown、has_more 等字段) \| pretty |
| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | | 文档 URL 或 token支持 `/docx/``/wiki/` |
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` |
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
| `--start-block-id` | 否 | `range`/`section` 起始/锚点 id`section` 必填) |
| `--end-block-id` | 否 | `range` 结束 id`-1` 表示读到末尾 |
| `--keyword` | 否 | `keyword` 模式关键词;`\|` 分隔多词 OR |
| `--context-before` | 否 | 命中前拉几个兄弟块(仅对顶层单元生效,默认 `0` |
| `--context-after` | 否 | 命中后拉几个兄弟块(仅对顶层单元生效,默认 `0` |
| `--max-depth` | 否 | `outline` = 标题层级上限;其它 = 子树深度(`-1` 不限,默认) |
| `--format` | 否 | `json`(默认)\| `pretty` |
## 重要:图片、文件、画板的处理
## 图片、文件、画板的处理
**文档中的图片、文件、画板需要通过独立的 media shortcut 单独获取。**
**文档中的素材以 XML 标签形式出现:**
### 识别格式
返回的 Markdown 中,媒体文件以 HTML 标签形式出现:
- **图片**
```html
<image token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc" width="1833" height="2491" align="center"/>
```
- **文件**
```html
<view type="1">
<file token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc" name="skills.zip"/>
</view>
```
- **画板**
```html
<whiteboard token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc"/>
```
- 画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
### 获取步骤
1. 从 HTML 标签中提取 `token` 属性值
2. 如果目标是图片/文件素材,且用户只是想查看/预览,调用 [`lark-doc-media-preview`](lark-doc-media-preview.md)`docs +media-preview`
```bash
lark-cli docs +media-preview --token "提取的token" --output ./preview_media
```
3. 如果用户明确要下载,或目标是 `<whiteboard token="..."/>`,调用 [`lark-doc-media-download`](lark-doc-media-download.md)`docs +media-download`
```bash
lark-cli docs +media-download --token "提取的token" --output ./downloaded_media
```
## Wiki URL 处理策略
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。当不确定类型时,**不能直接假设是云文档**,必须先查询实际类型。
### 处理流程
1. **先调用 lark-wiki 解析 wiki token**
2. **从返回的 `node` 中获取 `obj_type`(实际文档类型)和 `obj_token`(实际文档 token**
3. **根据 `obj_type` 调用对应工具**
| obj_type | 工具 | 说明 |
|----------|------|------|
| `docx` | `lark-doc-fetch` | 云文档 |
| `sheet` | `lark-sheet` | 电子表格 |
| `bitable` | `lark-base` | 多维表格 |
| 其他 | 告知用户暂不支持 | — |
## 重要任务卡片task 标签)
`docs +fetch` 默认不会查询/展开文档中内嵌的任务详情(例如任务标题、状态、负责人等)。
它会在返回的 Markdown 中保留任务引用,并返回任务 IDGUID例如
```html
<task task-id="30597dc9-262e-4597-97f4-f8efcd1aeb95"></task>
```xml
<img token="..." url="https://..." width="..." height="..."/>
<source token="..." url="https://..." name="skills.zip"/>
<whiteboard token="..."/>
```
如果用户需要查看该任务的详情,需要用返回的 `task-id` 再调用任务 CLI 查询:
- `<img>` / `<source>``url` 时,直接用该 URL 下载即可(普通 HTTP GET无需走 shortcut。
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut`docs +media-download --token <token> --output ./downloaded_media`
```bash
lark-cli task tasks get --as user --params '{"task_guid":"30597dc9-262e-4597-97f4-f8efcd1aeb95"}'
```
## 嵌入电子表格 / 多维表格
## 工具组合
| 需求 | 工具 |
|------|------|
| 获取文档文本 | `docs +fetch` |
| 预览图片/文件素材 | `docs +media-preview` |
| 下载图片/文件/画板 | `docs +media-download` |
| 创建新文档 | `docs +create` |
| 更新文档内容 | `docs +update` |
返回中可能含 `<sheet>``<bitable>``<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
## 参考

View File

@@ -0,0 +1,61 @@
# Markdown 格式参考
`docs +fetch / +create / +update` 使用 `--doc-format markdown` 时适用。
## 转义规则
> **⚠️ 当文本中包含以下字符且**不想**触发 Markdown 语法时,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。
### 无条件转义(行内生效,任何位置都要转义)
| 符号 | Markdown 语法用途 | 转义写法 | 示例 |
|------|-------------------|----------|------|
| `\` | 转义符本身 | `\\` | `C:\\Users` → C:\Users |
| `` ` `` | 行内代码 | `` \` `` | `` 用 \` 包裹 `` |
| `*` | 斜体 / 加粗 | `\*` | `3 \* 5 = 15` → 3 \* 5 = 15 |
| `_` | 斜体 / 加粗 | `\_` | `foo\_bar\_baz` → foo\_bar\_baz |
| `[` `]` | 链接文本 | `\[` `\]` | `\[非链接\]` |
| `$` | 数学公式定界 | `\$` | `价格 \$100` |
| `~` | 删除线GFM `~~text~~` | `\~` | `a\~\~b\~\~c` → a~~b~~c |
### 位置敏感转义(仅在特定位置才需要转义)
| 符号 | Markdown 语法用途 | 转义条件 | 示例 |
|------|-------------------|----------|------|
| `#` | 标题 | **仅行首**(去除前导空白后)| 行首 `\# 这不是标题`;行内 `A # B` 无需转义 |
| `+` | 无序列表 | **仅行首**(去除前导空白后)| 行首 `\+ item`;行内 `1 + 2` 无需转义 |
| `-` | 无序列表 / 分隔线 | **仅行首**(去除前导空白后)| 行首 `\- item`;行内 `A - B` 无需转义 |
| `>` | 引用块 | **仅行首**(去除前导空白后)| 行首 `\> 不是引用`;行内 `a > b` 无需转义 |
| `\|` | 表格 cell 分隔 | **仅在 GFM 表格 cell 内** | cell 内 `A \| B`;行内普通文本 `a \| b` 无需转义 |
**不需要转义的场景:**
- 在 `` ` `` 行内代码或 ` ``` ` 代码块内,所有符号均为字面量,无需转义
- `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响
**导出已转义,不要反转义:**
`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[``\|``\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
**写入时必须转义:**
使用 `docs +create``docs +update``--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。
**导出 → 更新 工作流示例:**
1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
2.`str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`
自行构造 Markdown 内容写入时同理:如字面文本 `a]b` 应写为 `a\]b``C:\Users` 应写为 `C:\\Users`
## 图片语法
Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下载:
```markdown
![alt text](https://example.com/photo.png)
```
- `alt text` 为图片描述(可选,可留空)
- URL 支持 `http://``https://` 协议
- 对应的 XML 格式为:`<img href="https://example.com/photo.png"/>`
## Markdown 不支持的 Block 类型
非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。

View File

@@ -8,6 +8,11 @@
## 命令
```bash
# 除了上传本地文件,还可以在 `docs +update` 时直接通过网络 URL 插入图片,无需先下载到本地:
lak-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "目标 block_id" \
--content '<img href="https://example.com/photo.png"/>'
# 插入图片(默认)
lark-cli docs +media-insert --doc doxcnXXX --file ./image.png

View File

@@ -1,264 +1,218 @@
# docs +update更新飞书云文档
> **前置条件** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证、全局参数和安全规则
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 3. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
> 4. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
更新飞书云文档内容,支持 7 种更新模式。优先使用局部更新replace_range/append/insert_before/insert_after慎用 overwrite会清空文档重写可能丢失图片、评论等
通过八种指令精确更新飞书云文档。支持字符串级别和 block 级别的操作
## 重要说明
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
## 命令
```bash
# 追加内容
lark-cli docs +update --doc "<doc_id_or_url>" --mode append --markdown "## 新章节\n\n追加内容"
# 定位替换(内容定位)
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-with-ellipsis "旧标题...旧结尾" --markdown "## 新内容"
# 定位替换(标题定位)
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n新内容"
# 全文替换
lark-cli docs +update --doc "<doc_id>" --mode replace_all --selection-with-ellipsis "张三" --markdown "李四"
# 前插入
lark-cli docs +update --doc "<doc_id>" --mode insert_before --selection-with-ellipsis "## 危险操作" --markdown "> 警告:以下需谨慎!"
# 后插入
lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-ellipsis "代码示例" --markdown "**输出示例**result = 42"
# 删除内容
lark-cli docs +update --doc "<doc_id>" --mode delete_range --selection-by-title "## 废弃章节"
# 覆盖(慎用)
lark-cli docs +update --doc "<doc_id>" --mode overwrite --markdown "# 全新内容"
# 同时更新标题
lark-cli docs +update --doc "<doc_id>" --mode append --markdown "## 更新日志" --new-title "文档 v2.0"
# 在指定内容后新增两个空白画板
lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-ellipsis "有序列表" --markdown $'<whiteboard type="blank"></whiteboard>\n<whiteboard type="blank"></whiteboard>'
```
> **⚠️ 格式选择规则:始终使用 XML 格式(默认),除非用户明确要求使用 Markdown。** 不要因为 Markdown 写起来更简单就自行切换为 Markdown。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | 是 | 文档 URL 或 token |
| `--mode` | 是 | 更新模式(见下方 7 种模式说明 |
| `--markdown` | 视模式 | 内容Lark-flavored Markdown。delete_range 模式不需要,其他模式必填。若要新增空白画板,直接传 `<whiteboard type="blank"></whiteboard>`;需要多个画板时,在同一个 markdown 里重复多个标签 |
| `--selection-with-ellipsis` | 视模式 | 内容定位(如 `"开头...结尾"`)。与 `--selection-by-title` 互斥 |
| `--selection-by-title` | 视模式 | 标题定位(如 `"## 章节名"`)。与 `--selection-with-ellipsis` 互斥 |
| `--new-title` | 否 | 同时更新文档标题 |
| `--command` | 是 | 操作指令(见下方指令速查表 |
| `--doc-format` | | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容 |
| `--pattern` | 视指令 | 匹配文本str_replace / str_delete |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID逗号分隔用于 block_copy_insert_after / block_move_after |
| `--revision-id` | 否 | 基准版本号,-1 = 最新(默认 `-1` |
# 定位方式
## 指令速查表
定位模式replace_range/replace_all/insert_before/insert_after/delete_range支持两种定位方式二选一
| 指令 | 说明 | 必需参数 |
|------|------|----------|
| `str_replace` | 全文文本查找替换replacement 支持富文本标签) | `--pattern` `--content` |
| `str_delete` | 全文文本查找删除 | `--pattern` |
| `block_insert_after` | 在指定 block 之后插入新内容 | `--block-id` `--content` |
| `block_copy_insert_after` | 复制源 block 并插入到锚点之后(源块不变) | `--block-id` `--src-block-ids` |
| `block_replace` | 替换指定 block同一 block 仅限一次) | `--block-id` `--content` |
| `block_delete` | 删除指定 block逗号分隔可批量 | `--block-id` |
| `overwrite` | ⚠️ 清空文档后全文重写(可能丢失图片、评论) | `--content` |
| `append` | 在文档末尾追加内容(等价于 `block_insert_after --block-id -1` | `--content` |
| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` + (`--content``--src-block-ids`) |
## selection-with-ellipsis - 内容定位
## 指令示例
支持两种格式:
### str_replace — 全文文本替换
1. **范围匹配**`开头内容...结尾内容`
- 匹配从开头到结尾的所有内容(包含中间内容)
- 建议 10-20 字符确保唯一性
```bash
# 简单文本替换
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "张三" --content "李四"
2. **精确匹配**`完整内容`(不含 `...`
- 匹配完整的文本内容
- 适合替换短文本、关键词等
# 替换为富文本(加粗 + 链接)
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "旧链接" --content '<b>新链接</b> <a href="https://example.com">点击查看</a>'
**转义说明**:如果要匹配的内容本身包含 `...`,使用 `\.\.\.` 表示字面量的三个点。
# 仅当用户明确要求时才使用 Markdown
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--doc-format markdown --pattern "旧内容" --content "新内容"
```
示例:
- `你好...世界` → 匹配从"你好"到"世界"之间的任意内容
- `你好\.\.\.世界` → 匹配字面量 "你好...世界"
### str_delete — 全文文本删除
**建议**:如果文档中有多个 `...`,建议使用更长的上下文来精确定位,避免歧义。
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_delete \
--pattern "废弃的内容"
```
## selection-by-title - 标题定位
### block_insert_after — 在指定 block 之后插入
格式:`## 章节标题`(可带或不带 # 前缀)
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "目标 block_id" \
--content '<h2>新章节</h2><ul><li>要点 1</li><li>要点 2</li></ul>'
```
自动定位整个章节(从该标题到下一个同级或更高级标题之前)。
### block_replace — 替换指定 block
**示例**
- `## 功能说明` → 定位二级标题"功能说明"及其下所有内容
- `功能说明` → 定位任意级别的"功能说明"标题及其内容
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \
--block-id "目标 block_id" \
--content '<p>替换后的段落内容</p>'
```
# 可选参数
### block_delete — 删除指定 block
## new-title
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_delete \
--block-id "目标 block_id"
```
更新文档标题。如果提供此参数,将在更新文档内容后同步更新文档标题。
### overwrite — 全文覆盖
**特性**
- 仅支持纯文本,不支持富文本格式
- 长度限制1-800 字符
- 可以与任何 mode 配合使用
- 标题更新在内容更新之后执行
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command overwrite \
--content '<title>全新文档</title><h1>概述</h1><p>新的内容</p>'
```
# 返回值
> ⚠️ 会清空文档后重写,可能丢失图片、评论等。仅在需要完全重建文档时使用。
## 成功
### append — 在文档末尾追加
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command append \
--content '<h2>新增章节</h2><p>追加的内容</p>'
```
> 等价于 `block_insert_after --block-id -1`,无需先获取 block ID。
### block_copy_insert_after — 复制块并插入
将一个或多个源块复制到锚点块之后,源块保持不变。`--src-block-ids` 为逗号分隔的源块 ID按顺序依次插入到锚点之后。
```bash
# 复制多个块按顺序插入anchor → a → b → c
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_copy_insert_after \
--block-id "锚点 block_id" \
--src-block-ids "block_a,block_b,block_c"
```
### block_move_after — 移动已有 block
将文档中已有的 block 移动到指定锚点之后。使用 `--src-block-ids` 指定要移动的块 ID无需 `--content`
```bash
# 移动到页面末尾
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_move_after \
--block-id "-1表示末尾page_id表示开头blk" \
--src-block-ids "block_a,block_b"
```
## 返回值
```json
{
"success": true,
"doc_id": "文档ID",
"mode": "使用的模式",
"board_tokens": ["可选:新建画板 token 列表"],
"message": "文档更新成功xxx模式",
"warnings": ["可选警告列表"],
"log_id": "请求日志ID"
"ok": true,
"identity": "user",
"data": {
"document": {
"revision_id": 13,
"newblocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "token": "boardXXXX" }
]
},
"result": "success",
"updated_blocks_count": 3,
"warnings": []
}
}
```
如果本次 `docs +update` 创建了画板,响应会额外返回 `board_tokens`。在 CLI 的成功 JSON 输出里,后续编辑画板应读取 `data.board_tokens`
| 字段 | 说明 |
|------|------|
| `result` | `success` \| `partial_success` \| `failed` |
| `updated_blocks_count` | 实际更新的 block 数量 |
| `warnings` | 警告信息列表 |
| `document.newblocks` | 本次操作新增的 block 列表(如画板),可从中提取 `token` 用于后续编辑 |
## 异步模式(大文档超时)
## 典型工作流
```json
{
"task_id": "async_task_xxxx",
"message": "文档更新已提交异步处理,请使用 task_id 查询状态",
"log_id": "请求日志ID"
}
```
### 精确 block 级更新
## 错误
1. **获取文档内容和 block ID**
```bash
lark-cli docs +fetch --api-version v2 --doc "<doc_id>" --detail with-ids
```
```json
{
"error": "[错误码] 错误消息\n💡 Suggestion: 修复建议\n📍 Context: 上下文信息",
"log_id": "请求日志ID"
}
```
2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
---
3. **执行更新**
```bash
# 替换特定 block
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \
--block-id "blkcnXXXX" --content "<p>新内容</p>"
# 使用示例
# 在某 block 后插入
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "blkcnXXXX" --content "<h2>追加的章节</h2>"
```
## append - 追加到末尾
### 简单文本替换
不需要 block ID直接匹配替换
```bash
lark-cli docs +update --doc "文档ID或URL" --mode append --markdown "## 新章节\n\n追加的内容..."
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "v1.0" --content "v2.0"
```
## replace_range - 定位替换
## 画板处理
使用 `--selection-with-ellipsis`
```bash
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-with-ellipsis "## 旧标题...旧结尾。" --markdown "## 新标题\n\n新的内容..."
```
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
使用 `--selection-by-title`(替换整个章节):
```bash
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n更新后的内容..."
```
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
## replace_all - 全文替换
## 最佳实践
```bash
lark-cli docs +update --doc "文档ID" --mode replace_all --selection-with-ellipsis "张三" --markdown "李四"
```
返回值包含 `replace_count` 字段,表示替换的次数。
**注意**
-`replace_range` 不同,`replace_all` 允许多个匹配
- 如果没有找到匹配内容,会返回错误
- `--markdown` 可以为空字符串,表示删除所有匹配内容
## delete_range - 删除内容
```bash
lark-cli docs +update --doc "文档ID" --mode delete_range --selection-by-title "## 废弃章节"
```
注意delete_range 模式不需要 `--markdown` 参数。
## overwrite - 完全覆盖
⚠️ 会清空文档后重写,可能丢失图片、评论等,仅在需要完全重建文档时使用。
```bash
lark-cli docs +update --doc "文档ID" --mode overwrite --markdown "# 新文档\n\n全新的内容..."
```
## 创建空白画板
当用户要“新增空白画板”时,不要用 Mermaid 占位图;直接按 whiteboard 标签传 `--markdown`
自然语言请求示例:
- “给我在这个文档末尾新增一个空白画板”
```bash
# 追加一个空白画板
lark-cli docs +update --doc "文档ID" --mode append --markdown '<whiteboard type="blank"></whiteboard>'
# 在指定内容后新增两个空白画板
lark-cli docs +update --doc "文档ID" --mode insert_after --selection-with-ellipsis "有序列表" --markdown $'<whiteboard type="blank"></whiteboard>\n<whiteboard type="blank"></whiteboard>'
```
成功后,响应里的 `data.board_tokens` 就是新建画板的 token 列表;如果后续要继续编辑这些画板,直接使用这些 token。
---
# 最佳实践
## 重要:画板编辑
> **⚠️ docs +update 不能编辑已有画板内容,但可以创建新的空白画板**
画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
## 小粒度精确替换
修改文档内容时,**定位范围越小越安全**。尤其是表格、分栏等嵌套块,应精确定位到需要修改的文本,避免影响其他内容。
## 保护不可重建的内容
图片、画板、电子表格、多维表格、任务等内容以 token 形式存储,**无法读出后原样写入**。
**保护策略**
- 替换时避开包含这些内容的区域
- 精确定位到纯文本部分进行修改
## 分步更新优于整体覆盖
修改多处内容时:
- ✅ 多次小范围替换,逐步修改
- ⚠️ 谨慎使用 `overwrite` 重写整个文档,除非你认为风险完全可控
**原因**:局部更新保留原有媒体、评论、协作历史,更安全可靠。
## insert 模式扩大定位范围时注意插入位置
使用 `insert_before``insert_after` 时,如果目标内容重复出现,需要扩大 `--selection-with-ellipsis` 范围来唯一定位。
**关键**:插入位置基于匹配范围的**边界**
- `insert_after` → 插入在匹配范围的**结尾**之后
- `insert_before` → 插入在匹配范围的**开头**之前
## 修复画板语法错误
`docs +create``docs +update` 返回画板写入失败的 warning 时:
1. warning 中包含 whiteboard 标签(如 `<whiteboard token="xxx"/>`
2. 分析错误信息,修正 Mermaid/PlantUML 语法
3.`--mode replace_range` 替换:`--selection-with-ellipsis` 使用 warning 中的 whiteboard 标签,`--markdown` 提供修正后的代码块
4. 重新提交验证
---
# 注意事项
- **Markdown 语法**:支持飞书扩展语法,详见 [lark-doc-create](lark-doc-create.md) 工具文档
- **精确操作优于全文覆盖**:使用 `block_replace`/`block_insert_after` 精确修改,避免 `overwrite` 全文覆盖
- **str_replace 适用于行内文本,容器操作优先用 block_replace**`str_replace` 只建议用来替换段落内/行内的短文本片段;一旦涉及整段、整块或容器级(列表、表格、分栏、引用块等)改动,应优先用 `block_replace` 指定 block_id 重建该容器,避免 `str_replace` 跨 block 匹配带来的副作用
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
2. 用 `block_delete` 批量删除旧的 block
3. 这样可以保留文档中其他不相关的内容(图片、评论等)
- **视觉丰富度**:插入或替换内容时,同样遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档
- [lark-doc-create](lark-doc-create.md) — 创建文档(含完整 Markdown 格式参考
- [lark-doc-media-insert](lark-doc-media-insert.md) — 插入图片/文件到文档
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,169 @@
基于 HTML 子集的 XML 格式描述飞书文档内容。
# 一、标准 HTML 标签
p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr, img, b, em, u, del, a, br, span 语义不变
# 二、扩展标签速查表
## 块级标签
|标签|说明|关键属性|
|-|-|-|
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
## 容器标签
|标签|说明|关键属性|
|-|-|-|
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
| `<whiteboard>` | 嵌入画板 | `type`: `mermaid` \| `plantuml` \| `blank` |
| `<pre>` | (代码块,内含 `code`| `lang`, `caption` |
| `<figure>` | 视图容器 | `view-type` |
| `<bookmark>` | 书签链接 | `<bookmark name="标题" href="https://..."></bookmark>`,必传 name 和 href |
## 行内组件
| 标签 | 说明 | 关键属性 |
|-|-|-|
| `<cite type="user">` | @人 | `<cite type="user" user-id="userID"></cite>` |
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
| `<source>` | 文件附件(可独立成块或内联) | `<source name="报告.pdf"/>` |
| `<a type="url-preview">` | 预览卡片 | `<a type="url-preview" href="...">标题</a>` |
| `<button>` | 操作按钮 | `background-color`,src,必须包含 `action=OpenLink|DuplicatePage|FollowPage` |
| `<time>` | 提醒 | `必包含 expire-time、notify-time=毫秒时间戳、should-notify=true|false` |
## 文本块通用属性
- `align``"left"`|`"center"`|`"right"`(适用于 p / h1-h9 / li / checkbox
- 有序列表项用 `seq="auto"` 自动编号
# 三、资源块
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>``<whiteboard type="blank"></whiteboard>` 空白;`<whiteboard type="mermaid|plantuml">内容</whiteboard>` 带内容;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
# 四、块级复制与移动
## 移动block_move_after
支持**所有**块类型(块级标签、容器标签、行内组件、资源块),使用 `docs +update --command block_move_after --block-id "<锚点>" --src-block-ids "id1,id2"`
## 复制block_copy_insert_after
- **基础标签**(块级标签、容器标签、行内组件):均支持复制
- **资源块**:仅 img、source、whiteboard、sheet、chat_card 支持复制task、bitable、base_ref、synced_reference、synced_source、okr 不支持复制
使用 `docs +update --command block_copy_insert_after --block-id "<锚点>" --src-block-ids "id1,id2"`
> 详见 [lark-doc-update.md](lark-doc-update.md)。
# 五、补充规则
## 富文本样式嵌套顺序
- 行内样式标签必须按以下固定顺序嵌套(外 → 内),关闭顺序严格反转:`<a> → <b> → <em> → <del> → <u> → <code> → <span> → 文本内容`
## 列表分组
- 连续同类型列表项自动合并为一个 `<ul>``<ol>`
- 嵌套子列表放在 `<li>` 内部
- 新增列表项必须包在 `<ul>``<ol>` 内:
```xml
<ul>
<li>第一项</li>
<li>第二项</li>
</ul>
```
## 表格扩展
标准 HTML table 结构不变,扩展点:
- `<colgroup>` / `<col>` 定义列宽,紧跟 `<table>` 之后:`<col span="2" width="100"/>`
- `<th>` / `<td>` 增加 `background-color` 和 `vertical-align`top | middle | bottom
- 有表头时第一行在 `<thead>` 用 `<th>`,其余在 `<tbody>` 用 `<td>`
- 合并单元格仅起始格输出 `colspan` / `rowspan`,被合并的格不出现
# 六、美化系统
- 颜色优先使用命名色,也可写 `rgb(r,g,b)` / `rgba(r,g,b,a)`。**基础色7 色)**gray, red, orange, yellow, green, blue, purple
| 属性 | 支持的命名色 |
|-|-|
| 文字颜色 `<span text-color>` | 基础色 |
| 高亮框字色 `<callout text-color>` | 基础色 |
| 高亮框边框 `<callout border-color>` | 基础色 |
| 文字背景 `<span background-color>` | 基础色 + `light-{色}` + `medium-gray` |
| 高亮框填充 `<callout background-color>` | `gray` + `light-{色}` + `medium-{色}` |
| 单元格背景 `<th/td background-color>` | 同文字背景 |
| 按钮背景 `<button background-color>` | 同文字背景 |
- 常用 emoji 💡(默认)✅❌⚠️📝❓❗👍❤️📌🏁⭐
# 七、**重要规则**
## 转义规则:标签本身 **禁止转义**,只有标签内部的文本内容才需要转义
**错误** ❌:`&lt;p&gt;内容&lt;/p&gt;`(把标签也转义了)
**正确** ✅:`<p>A &amp; B 的对比1 &lt; 2</p>`(标签保持原样,文本中的 `&` 和 `<` 才转义)
转义字符表:
- `<` → `&lt;`
- `>` → `&gt;`
- `&` → `&amp;`
- `\n`(换行符) → `<br/>`
## 八、完整示例
```xml
<title>文档标题</title>
<h1>一级标题</h1>
<p><b>加粗文本</b><span text-color="green">绿色文本</span></p>
<callout emoji="💡" background-color="light-yellow" border-color="yellow">
<p>高亮框内容,子块仅支持文本/标题/列表/待办/引用</p>
</callout>
<checkbox done="true">已完成事项</checkbox>
<checkbox done="false">未完成事项</checkbox>
<grid>
<column width-ratio="0.5">
<p>左栏</p>
</column>
<column width-ratio="0.5">
<p>右栏</p>
</column>
</grid>
<table>
<colgroup><col span="2" width="120"/></colgroup>
<thead><tr><th background-color="light-gray">表头</th><th background-color="light-gray">表头</th></tr></thead>
<tbody><tr><td>单元格</td><td>单元格</td></tr></tbody>
</table>
<p><cite type="doc" doc-id="DOC_TOKEN"></cite> <cite type="user" user-id="USER_ID"></cite></p>
<ol><li seq="auto">第一项</li><li seq="auto">第二项</li></ol>
<p><a type="url-preview" href="https://example.com">链接标题</a></p>
<p><latex>E = mc^2</latex></p>
<pre lang="go" caption="示例"><code>fmt.Println("hello")</code></pre>
<hr/>
<source name="文件名.pdf"/>
<img src="IMG_TOKEN" width="800" height="400" caption="说明" name="图.png"/>
<img href="https://example.com/photo.png"/>
<button action="OpenLink" src="https://example.com">按钮文字</button>
<time expire-time="1775916000000" notify-time="1775912400000" should-notify="false">时间戳毫秒</time>
<cite type="citation"><a href="https://example.com">引文标题</a></cite>
<bookmark name="书签标题" href="https://example.com"></bookmark>
<task task-id="TASK_GUID"></task>
<chat_card chat-id="CHAT_ID"></chat_card>
```

View File

@@ -0,0 +1,50 @@
# 从零创作工作流
用户提供主题、需求或简要说明,需要生成一份新的飞书文档时,遵循本工作流。
## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
3. **Observe观察** — 检查命令输出,验证正确性,核查样式是否达标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
循环在文档达到质量标准且满足用户需求时结束。不要试图一次性产出完美内容——迭代打磨效果更好。根据用户实际需求灵活决定文档结构和版块,而不是套用固定模板。
## 典型 Code-Act Loop 流程
### 第一波 — 规划与骨架(串行)
1. 分析用户需求:受众、目的、范围
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block
3. `docs +create --api-version v2` 创建文档:标题 + 开头 `<callout>` + 骨架(各级标题 + 简短占位摘要)
### 第二波 — 内容撰写(并行 Agent
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、期望的 block 类型
- `lark-doc-xml.md``lark-doc-style.md` 的完整路径Agent 须先读取)
- 使用 `docs +update --command append``block_insert_after` 写入
### 第三波 — 整合审查 + 画板意图识别(串行)
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。记录需要插图的章节及推荐的画板类型
### 第四波 — 润色与图表(并行 Agent
8. Spawn Agent 定向改进:(结合 `lark-doc-style.md` 进润)
- **优先处理第三波识别出的画板需求**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
- 文字密集章节转为 `<table>`/`<grid>`/`<callout>`
- 主要章节间补充 `<hr/>`
- 本地图片使用 `docs +media-insert` 插入
## Agent 子任务要求
Spawn Agent 时必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
章节较多时,先 `docs +create` 建骨架,再分段 `append` 追加,比一次性超长 `--content` 更可靠。

View File

@@ -0,0 +1,97 @@
# 文档样式指南
创建或编辑文档时,必须遵循本指南,使用结构化 block 提升可读性和视觉层次。
## 一、核心原则
1. **结构优于文字**:能用结构化 block 表达的信息,不用纯文本段落
2. **Front-load 结论**:文档以 `<callout>` 开头概括核心结论;每章节首段点明要旨
3. **视觉节奏**:连续纯文本不超过 3 段;不同主题章节间用 `<hr/>` 分隔
4. **最少惊讶**:同类信息使用同类元素,全篇风格统一
## 二、元素选择指南
涉及图表需求时,简单图用 `<whiteboard type="mermaid/plantuml">` 内嵌,复杂图使用 **lark-whiteboard** skill。
| 场景 | 推荐方案 |
|-|-|
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
| 方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏 |
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
| 任务清单 / 检查项 | `<checkbox>` |
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 简单流程图 / 时序图 / 状态机 / 甘特图 | `<whiteboard type="mermaid/plantuml">` |
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | **lark-whiteboard** skill |
### 画板意图识别
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本:
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|-|-|-|
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
**判断规则:**
- 简单图(节点 ≤ 10、无需精细排版`<whiteboard type="mermaid/plantuml">` 内嵌
- 复杂图(节点 > 10、需自定义布局/样式、数据图表)→ spawn Agent 使用 **lark-whiteboard** skill
### 画板语法与插入
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
#### 内嵌 Mermaid / PlantUML首选
简单图直接用 `<whiteboard type="mermaid|plantuml">语法</whiteboard>`,作为 block 嵌入文档。
#### DSL 画板Mermaid / PlantUML 不够用时)
需要架构图、对比图、组织架构等复杂结构时:
1.`<whiteboard type="blank"></whiteboard>` 通过 `docs +create` / `docs +update` 插入空白画板
2. 从响应 `data.document.newblocks` 中提取画板 `token`
3. 切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 设计并上传 DSL
更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
## 三、颜色语义
全篇保持语义一致,同一语义必须使用同一颜色:
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|-|-|-|-|
| 信息、说明 | "说明:" | `light-blue` | `blue` |
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
| 中性、辅助 | — | `light-gray` | — |
- 表头统一 `background-color="light-gray"`
- 关键指标用 `<span text-color="green/red">` 突出,**必须同时用 ↑↓ 或 +/- 标注方向**(色觉无障碍)
## 四、排版规范
- 标题层级 ≤ 4 层,段落单段 ≤ 5 行,列表嵌套 ≤ 2 层Grid ≤ 3 列
- 文档开头用 `<callout>` front-load 结论;
## 五、丰富度自检
生成内容后必须自检,**未达标时主动优化**
| 指标 | 达标标准 |
|-|-|
| 富 block 密度 | ≥ 40%(非纯文本 block 数 ÷ 总 block 数) |
| 元素多样性 | ≥ 3 种不同 block 类型 |
| 连续纯文本 | ≤ 3 段连续 `<p>` |
| 章节丰富度 | 每 h1/h2 ≥ 1 个非纯文本 block |
| 开头 callout | 必须 |
| 视觉节奏 | 不同主题章节间有 `<hr/>` |

View File

@@ -0,0 +1,48 @@
# 改写增强工作流
用户提供已有文档链接或 token需要改写、润色、补充或重排版时遵循本工作流。
## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
3. **Observe观察** — 检查命令输出,验证正确性,核查样式是否达标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
## 核心原则:精准手术优于全量覆盖
1. **精准手术**:只改用户指定的 block不改其他 block。
2. **全量覆盖**:如果用户明确要改整篇,才用 `overwrite` 命令。
3. **保真约束**:改写时原文里的 `<cite type="user">`@人)、`<cite type="doc">`@文档)、`<img>``<source>``<whiteboard>``<sheet>``<bitable>``<synced_reference>` 等行内组件和资源块一律原样保留(含所有 token / user-id / doc-id 属性),不许替换成纯文本姓名、链接或占位符。
## 工作流程
### 第一波 — 分析 + 画板意图识别(串行)
1. **选择读取范围**(节省上下文的关键):
- 用户只改某一节 / 文档较大 → 先 `--scope outline --max-depth 2` 拿目录,再 `--scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id
- 需要精确跨节区间 → `--scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
- 用户只给了模糊关键词 → `--scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
2. 系统性评估:结构清晰度、富 block 密度≥40%、元素多样性≥3种、连续 `<p>` 是否超过 3 段、是否有开头 callout 和章节 `<hr/>`
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。记录需要插图的章节block ID及推荐的画板类型
4. 向用户简要说明改进计划(包含识别出的画板机会)
### 第二波 — 定向改写(并行 Agent
5. Spawn Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID`lark-doc-style.md`
- 开头适当添加 `<callout>`、重组引言
- 纯文本转为 `<grid>`/`<table>`/`<whiteboard>`
- **对第一波识别出的画板候选段落**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
- 添加流程图、对比分栏等富 block
### 第三波 — 验证(串行)
5. 获取更新后文档局部内容,重新检查样式指标
6. 未达标则定向修正,向用户呈现结果
## Agent 子任务要求
Spawn Agent 时必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
**上下文节省提示**Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。

View File

@@ -114,9 +114,9 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--selection-with-ellipsis` 或 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--selection-with-ellipsis` / `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | `file_token` | 同添加评论 |
@@ -124,8 +124,8 @@ Drive Folder (云空间文件夹)
### 评论能力边界(关键!)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--selection-with-ellipsis` / `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--selection-with-ellipsis` 或 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。

View File

@@ -24,22 +24,22 @@ lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--content '[{"type":"text","text":"这里需要一段全文评论"}]'
# 给 docx 文档里匹配到的文字添加局部评论
# 给 docx 文档的指定 block 添加局部评论block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取)
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--selection-with-ellipsis "流程" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请补充流程说明"}]'
# wiki 链接也支持局部评论,但解析结果必须是 docx
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--selection-with-ellipsis "流程" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请补充更细的开发步骤"}]'
# 组合文本、@用户、链接元素
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--selection-with-ellipsis "流程" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
# 给电子表格单元格添加评论(--block-id 格式为 <sheetId>!<cell>
@@ -68,7 +68,7 @@ lark-cli drive +add-comment \
lark-cli drive +add-comment \
--doc "<DOCX_TOKEN>" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"评论内容"}]'
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
# 如果需要更底层的原生 API也可以直接调用 V2 协议
lark-cli schema drive.file.comments.create_v2
@@ -80,7 +80,7 @@ lark-cli drive file.comments create_v2 \
# 预览底层调用链
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--selection-with-ellipsis "流程" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请补充流程说明"}]' \
--dry-run
```
@@ -92,20 +92,21 @@ lark-cli drive +add-comment \
| `--doc` | 是 | 文档 URL / token、sheet URL或可解析到 `doc`/`docx`/`sheet` 的 wiki URL |
| `--type` | 裸 token 时必填 | 文档类型:`doc``docx``sheet`。URL 输入时自动识别,无需传 |
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论 |
| `--block-id` | 局部评论时必填 | 目标块 ID可通过 `docs +fetch --api-version v2 --detail with-ids` 获取 |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论(不适用于 sheet |
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥(不适用于 sheet |
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6` |
## 行为说明
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`)。此时 `--full-comment``--selection-with-ellipsis` 不可用。
- **无需预先获取文档内容**:使用 `--selection-with-ellipsis`shortcut 内部会自动调用 `locate-doc` 定位目标文本,不需要先调用 `docs +fetch` 获取文档。
- 未传 `--selection-with-ellipsis` / `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`
- 全文评论支持 `docx`、旧版 `doc` URL以及最终可解析为 `doc`/`docx` 的 wiki URL。
-`--selection-with-ellipsis``--block-id`shortcut 创建**局部评论(划词评论)**;该模式仅支持 `docx`,以及最终可解析为 `docx` 的 wiki URL。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式仅支持 `docx`,以及最终可解析为 `docx` 的 wiki URL。
- `--content` 接收结构化评论元素数组;`type` 支持 `text``mention_user``link`。为便于书写,`mention_user` / `link` 元素可以直接把用户 ID 或链接地址放在 `text` 字段中shortcut 会转换成 OpenAPI 所需字段。
- 局部评论走 `locate-doc` 时,内部固定使用 `limit=10`
-`locate-doc` 命中多处时shortcut 会中止并提示用户继续收窄 `--selection-with-ellipsis`,不支持手动指定匹配序号。
- 写入评论前会自动生成符合 OpenAPI 定义的请求体:
- 统一接口:`POST /new_comments`
- 统一字段:`file_type` + `reply_elements`

View File

@@ -195,7 +195,7 @@ lark-cli event +subscribe \
content=$(echo "$line" | jq -r '.content // empty')
[[ -z "$content" ]] && continue
lark-cli docs +update --doc "DOC_URL" --mode append --markdown "- $content"
lark-cli docs +update --api-version v2 --doc "DOC_URL" --command append --doc-format markdown --content "- $content"
done
```

View File

@@ -35,7 +35,7 @@ metadata:
3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 `<whiteboard>`** 标签是封面图AI 生成的总结可视化),应同时下载展示给用户:
```bash
# 1. 读取纪要内容
lark-cli docs +fetch --doc <note_doc_token>
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
# 2. 从返回的 markdown 中提取第一个 <whiteboard token="xxx"/> 的 token
# 3. 下载封面图到 artifact 目录(和逐字稿同目录,保持产物归拢)
# 并非所有纪要都有封面画板,没有 <whiteboard> 标签时跳过即可
@@ -63,7 +63,7 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
3. 需要获取文档内容时,使用 `lark-cli docs +fetch`
```bash
# 获取文档内容
lark-cli docs +fetch --doc <doc_token>
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
```
## 资源关系

View File

@@ -57,7 +57,7 @@ metadata:
|---|---|
| 直接给了 whiteboard token`wbcnXXX`| 直接使用 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --doc <doc_id> --mode append --markdown '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.board_tokens[0]` 取得(参数详见 lark-doc SKILL.md|
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.board_tokens[0]` 取得(参数详见 lark-doc SKILL.md|
**Step 2渲染 & 写入**

View File

@@ -92,9 +92,9 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
阅读 [`../lark-doc/SKILL.md`](../lark-doc/SKILL.md) 学习云文档技能。
```bash
lark-cli docs +create --title "会议纪要汇总 (<start> - <end>)" --markdown "<内容>"
lark-cli docs +create --api-version v2 --doc-format markdown --content "<title>会议纪要汇总 (<start> - <end>)</title>\n<内容>"
# 或追加到已有文档
lark-cli docs +update --doc "<url_or_token>" --mode append --markdown "<内容>"
lark-cli docs +update --api-version v2 --doc "<url_or_token>" --command append --doc-format markdown --content "<内容>"
```
## 参考