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 "
正文
", + "--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内容
' +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` 选择、局部读取策略、`首段内容
' -# 创建到知识库节点下 -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 转给自己,必须单独确认。 -## 重要:创建文档后的可视化流程 - -如果文档中包含空白画板(``),可用于 `+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` 精读该节。
+
+## 局部读取的输出结构:` 文档内容... 替换后的段落内容 新的内容 追加的内容 新内容` 本身是顶层块也只返回 thead + 命中 tr。想拿整张表 → `range --start-block-id
+
` / `
`
+
+## Markdown 不支持的 Block 类型
+
+非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。
diff --git a/skills/lark-doc/references/lark-doc-media-insert.md b/skills/lark-doc/references/lark-doc-media-insert.md
index 301de79e6..71216fba0 100644
--- a/skills/lark-doc/references/lark-doc-media-insert.md
+++ b/skills/lark-doc/references/lark-doc-media-insert.md
@@ -8,6 +8,11 @@
## 命令
```bash
+# 除了上传本地文件,还可以在 `docs +update` 时直接通过网络 URL 插入图片,无需先下载到本地:
+lak-cli docs +update --api-version v2 --doc "
'
+
# 插入图片(默认)
lark-cli docs +media-insert --doc doxcnXXX --file ./image.png
diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md
index 434e3a952..6e116c5d7 100644
--- a/skills/lark-doc/references/lark-doc-update.md
+++ b/skills/lark-doc/references/lark-doc-update.md
@@ -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 "
新章节
'
+```
-自动定位整个章节(从该标题到下一个同级或更高级标题之前)。
+### block_replace — 替换指定 block
-**示例**:
-- `## 功能说明` → 定位二级标题"功能说明"及其下所有内容
-- `功能说明` → 定位任意级别的"功能说明"标题及其内容
+```bash
+lark-cli docs +update --api-version v2 --doc "概述
新增章节
追加的章节
"
+ ```
-## append - 追加到末尾
+### 简单文本替换
+
+不需要 block ID,直接匹配替换:
```bash
-lark-cli docs +update --doc "文档ID或URL" --mode append --markdown "## 新章节\n\n追加的内容..."
+lark-cli docs +update --api-version v2 --doc "` | (代码块,内含 `code`)| `lang`, `caption` |
+| `
` | 图片(可独立成块或内联) | `
` |
+| `