diff --git a/README.md b/README.md index 7a23eee44..7fb715d04 100644 --- a/README.md +++ b/README.md @@ -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 "Weekly Report\n# Progress\n- Completed feature X" ``` Run `lark-cli --help` to see all shortcut commands. diff --git a/README.zh.md b/README.zh.md index 5f68b880d..124c872b7 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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 "周报\n# 本周进展\n- 完成了 X 功能" ``` 运行 `lark-cli --help` 查看所有快捷命令。 diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 7d9685a13..48ccc19c5 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -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. diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 48ca7ece7..9ca4b9287 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -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. diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index 69ec15c85..7ce75026f 100644 --- a/shortcuts/doc/docs_create.go +++ b/shortcuts/doc/docs_create.go @@ -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, "项目计划

目标

", + "--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", "内容

正文

", + "--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", "内容

正文

", + "--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", "内容

正文

", + "--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{} { diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go new file mode 100644 index 000000000..916b540ae --- /dev/null +++ b/shortcuts/doc/docs_create_v2.go @@ -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 + } +} diff --git a/shortcuts/doc/docs_fetch.go b/shortcuts/doc/docs_fetch.go index d1ad3af23..880cbfc08 100644 --- a/shortcuts/doc/docs_fetch.go +++ b/shortcuts/doc/docs_fetch.go @@ -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 +} diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go new file mode 100644 index 000000000..f47ec6883 --- /dev/null +++ b/shortcuts/doc/docs_fetch_v2.go @@ -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 JSON;full/空模式返回 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) + } +} diff --git a/shortcuts/doc/docs_update.go b/shortcuts/doc/docs_update.go index ea80550ed..ef7c61d44 100644 --- a/shortcuts/doc/docs_update.go +++ b/shortcuts/doc/docs_update.go @@ -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 , 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 , 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, "\n" @@ -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, "") + normalizeWhiteboardResult(result, "") 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, "") + normalizeWhiteboardResult(result, "") 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") diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go new file mode 100644 index 000000000..bd0841105 --- /dev/null +++ b/shortcuts/doc/docs_update_v2.go @@ -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 +} diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go index fb58251dc..33ab08f21 100644 --- a/shortcuts/doc/helpers.go +++ b/shortcuts/doc/helpers.go @@ -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 { diff --git a/shortcuts/doc/versioned_help.go b/shortcuts/doc/versioned_help.go new file mode 100644 index 000000000..b11ae774e --- /dev/null +++ b/shortcuts/doc/versioned_help.go @@ -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) +} diff --git a/skill-template/domains/drive.md b/skill-template/domains/drive.md index 521e78f37..8d7b831d8 100644 --- a/skill-template/domains/drive.md +++ b/skill-template/domains/drive.md @@ -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`。 diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 20101067e..3069f4e2e 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -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 '标题

内容

' +lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '

内容

' ``` -返回结果示例: -```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` 选择、局部读取策略、`` / `` 输出结构) +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` 处理 +- 文档内容中出现嵌入的 ``、`` 或 `` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身 -## 补充说明 -`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。 +| 标签 / 属性 | 提取字段 | 切到技能 | +|-|-|-| +| `` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) | +| `` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) | +| `` | 同 `` | [`lark-sheets`](../lark-sheets/SKILL.md) | +| `` | 同 `` | [`lark-base`](../lark-base/SKILL.md) | +| `` | `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 + [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. | diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md index b23326f43..de2244ae8 100644 --- a/skills/lark-doc/references/lark-doc-create.md +++ b/skills/lark-doc/references/lark-doc-create.md @@ -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 '项目计划

目标

  • 目标 1
  • 目标 2
' -# 创建到指定文件夹 -lark-cli docs +create --title "会议纪要" --folder-token fldcnXXXX --markdown "## 讨论议题\n\n1. 进度\n2. 计划" +# 创建到指定文件夹(XML) +lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '标题

首段内容

' -# 创建到知识库节点下 -lark-cli docs +create --title "技术文档" --wiki-node wikcnXXXX --markdown "## API 说明" +# 创建到个人知识库(XML) +lark-cli docs +create --api-version v2 --parent-position my_library --content '标题

内容

' -# 创建到知识空间根目录 -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 转给自己,必须单独确认。 -## 重要:创建文档后的可视化流程 - -如果文档中包含空白画板(``),**必须继续以下步骤**: - -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-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 '\n重要需求说明\n' -``` - -# 内容格式 - -文档内容使用 **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 -# 一级标题 -## 二级标题 -### 三级标题 -#### 四级标题 -##### 五级标题 -###### 六级标题 -七级标题 -八级标题 -九级标题 - -# 带颜色的标题 {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 ---- -``` - ---- - -## 富文本格式 - -### 文本样式 - -`**粗体**` `*斜体*` `~~删除线~~` `` `行内代码` `` `下划线` - -### 文字颜色 - -`红色` `黄色背景` - -支持: red, orange, yellow, green, blue, purple, gray - -### 链接 - -`[链接文字](https://example.com)` (不支持锚点链接) - -### 行内公式(LaTeX) - -`$E = mc^2$`(`$`前后需空格)或 `E = mc^2`(无限制,推荐) - ---- - -## 高级块类型 - -### 高亮块(Callout) - -```html - -支持**格式化**的内容,可包含多个块 - -``` - -**属性**: 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 - - - -左栏内容 - - - - -右栏内容 - - - -``` - -#### 三栏自定义宽度 - -```html - -左栏(20%) -中栏(60%) -右栏(20%) - -``` - -**属性**: `cols`(列数 2-5), `width`(列宽百分比,总和为 100,等宽时可省略) - -### 表格 - -#### 标准 Markdown 表格 - -```markdown -| 列 1 | 列 2 | 列 3 | -|------|------|------| -| 单元格 1 | 单元格 2 | 单元格 3 | -| 单元格 4 | 单元格 5 | 单元格 6 | -``` - -#### 飞书增强表格 - -当单元格需要复杂内容(列表、代码块、高亮块等)时使用。 - -**层级结构**(必须严格遵守): -``` - <- 表格容器 - <- 行(直接子元素只能是 lark-tr) - 内容 <- 单元格(直接子元素只能是 lark-td) - 内容 <- 每行的 lark-td 数量必须相同! - - -``` - -**属性**: -- `column-widths`:列宽,逗号分隔像素值,总宽约 730 -- `header-row`:首行是否为表头(`"true"` 或 `"false"`) -- `header-column`:首列是否为表头(`"true"` 或 `"false"`) - -**单元格写法**:内容前后必须空行 -```html - - -这里写内容 - - -``` - -**完整示例**(2行3列): -```html - - - - -**表头1** - - - - -**表头2** - - - - -**表头3** - - - - - - -普通文本 - - - - -- 列表项1 -- 列表项2 - - - - -代码内容 - - - - -``` - -**限制**:单元格内不支持 Grid 和嵌套表格 - -**合并单元格**:读取时返回 `rowspan/colspan` 属性,创建暂不支持 - -**禁止**: -- 混用 Markdown 表格语法(`|---|`) -- 使用 `
` 换行 -- 遗漏 `` 标签 - -### 图片 - -```html - -``` - -**属性**: url (必需,系统会自动下载并上传), width, height, align (left/center/right), caption - -**注意**: 不支持直接使用 `token` 属性(如 ``),只支持 URL 方式。系统会自动下载图片并上传到飞书。 - -支持 PNG/JPG/GIF/WebP/BMP,最大 10MB - -**图片/文件插入方式选择**: -- **有公开可访问的图片 URL** → 直接在 `docs +create` / `docs +update` 的 markdown 中使用 `` 一步到位 -- **本地图片或文件** → 先用 `docs +create` / `docs +update` 创建或更新文档文本内容,再用 `lark-doc-media-insert`(docs +media-insert)将本地图片或文件追加到文档末尾 - -### 文件 - -```html - -``` - -**属性**: -- url (文件 URL,必需,系统会自动下载并上传) -- name (文件名,必需) -- view-type (1=卡片视图, 2=预览视图,可选) - -**注意**: 不支持直接使用 `token` 属性(如 ``) - -### 画板 - -创建空白画板时,直接在 markdown 中写 ``。 - -自然语言请求示例: -- “帮我创建一个带单个空白画板的文档” -- “帮我创建一个文档,里面放两个空白画板” - -```bash -# 创建带单个空白画板的文档 -lark-cli docs +create --title "空白画板示例" --markdown '' -``` - -```html - -``` - -一次创建多个空白画板时,在同一个 markdown 里重复多个标签: - -```html - - -``` - -#### 读取画板 - -读取时返回 `` 标签: - -```html - -``` - -**重要说明**: -- 创建空白画板时,直接使用 `` -- 读取时只能获取 token,可通过 media-download 查看内容,无法直接读出画板内部内容 -- 画板编辑:详见 [../../lark-whiteboard/SKILL.md](../../lark-whiteboard/SKILL.md) - -### 多维表格(Base) - -```html - - -``` - -**属性**: view (table/kanban,默认 table) - -**注意**: token 是只读属性,创建时不能指定。只能创建空的多维表格,创建后再手动添加数据。 - -### 会话卡片(ChatCard) - -```html - -``` - -**属性**: id (格式 oc_xxx, 必需), align (left/center/right) - -### 内嵌网页(Iframe) - -```html -