涉及的节点类别包括:
` + + `x\x08y
" + got := mustRenderAnchoredMarkdown(t, xml, nil, 0) + + if !strings.Contains(got, "# AB {#blk}") { + t.Errorf("heading should render with control char stripped, got:\n%s", got) + } + if !strings.Contains(got, "xy") { + t.Errorf("paragraph should render with control char stripped, got:\n%s", got) + } + if strings.ContainsAny(got, "\x0c\x08") { + t.Errorf("output still carries a control char:\n%q", got) + } +} + +func TestStripInvalidXMLChars_FastPathReturnsCleanInputUnchanged(t *testing.T) { + t.Parallel() + clean := "fmt.Println("hi")`
+ got := mustRenderAnchoredMarkdown(t, xml, nil, 0)
+ if !strings.Contains(got, "```go") || !strings.Contains(got, `fmt.Println("hi")`) {
+ t.Errorf("code fence wrong:\n%s", got)
+ }
+ if strings.Contains(got, "{#c}") {
+ t.Errorf("code must not get anchor, got:\n%s", got)
+ }
+}
+
+func TestRenderAnchoredMarkdown_ImageAnchoredAndJoined(t *testing.T) {
+ t.Parallel()
+ metas := map[string]*ImageMeta{
+ "imgtok": {Caption: "架构图"},
+ }
+ xml := `| 姓名 | 分数 |
|---|---|
| 张三 | 90 |
| 李四 | 85 |
| 边 | 方式 |
|---|---|
| 文档-文档 | 引用 |
| n |
|---|
| r |
| 业务 | poc |
|---|---|
| 知识问答 | @崔 |
| n |
|---|
| r |
a & b < c > d " e ' f
`, nil, 0) + if !strings.Contains(got, `a & b < c > d " e ' f`) { + t.Errorf("entities not un-escaped:\n%s", got) + } +} + +func TestRenderAnchoredMarkdown_ToleratesBareLessThan(t *testing.T) { + t.Parallel() + xml := `cmd < ok
` + + `heredoc: <<'EOF'
` + + `range a < b
` + + `| a|b | c |
| col |
|---|
| <qa:image>anchor="b" image_token="K1" w="0" h="0"</qa> |
| <qa:image>image_token="K2"</qa> |
| line1 line2 | a b |
" + text + "
", + }, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/docx/doxcnAnchoredSpill", + "--doc-format", "markdown", + "--full", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("docs +fetch anchored Markdown error = %v", err) + } + _, document := decodeDocsSpillEnvelope(t, stdout.Bytes()) + assertDocsSpillFile(t, document, wantContent) +} + +func TestDocsFetchFullDocumentAPIFallbackReturnsInlineContent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + const wantContent = "document API fallback content\n" + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-fallback")) + primaryStub := &httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + } + reg.Register(primaryStub) + fallbackStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxcnNativeFallback/fetch", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcnNativeFallback", + "content": wantContent, + }, + }, + }, + } + reg.Register(fallbackStub) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/docx/doxcnNativeFallback", + "--doc-format", "markdown", + "--full", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("docs +fetch fallback error = %v", err) + } + _, document := decodeDocsSpillEnvelope(t, stdout.Bytes()) + if got := document["content"]; got != wantContent { + t.Fatalf("content = %#v, want %q", got, wantContent) + } + if _, ok := document["content_file"]; ok { + t.Fatalf("small fallback content unexpectedly spilled: %#v", document) + } + if len(primaryStub.CapturedBodies) != 1 || len(fallbackStub.CapturedBodies) != 1 { + t.Fatalf("calls: primary=%d fallback=%d, want one each", len(primaryStub.CapturedBodies), len(fallbackStub.CapturedBodies)) + } +} + +func TestApplyFetchContentDeliveryDoesNotMutateScannedData(t *testing.T) { + originalDocument := map[string]interface{}{"content": "body", "title": "title"} + original := map[string]interface{}{"document": originalDocument} + emitted := cloneFetchDocumentData(original) + applyFetchContentDelivery(emitted, common.FetchContentDelivery{ + File: &common.FetchContentFile{Path: "/tmp/body.md"}, + Preview: "preview", + }) + + if originalDocument["content"] != "body" { + t.Fatalf("original document was mutated: %#v", originalDocument) + } + if _, ok := emitted["document"].(map[string]interface{})["content"]; ok { + t.Fatalf("emitted document retained inline content: %#v", emitted) + } +} + +func TestApplyFetchContentDeliveryAddsInlineFallbackHint(t *testing.T) { + data := map[string]interface{}{"document": map[string]interface{}{"content": "body"}} + applyFetchContentDelivery(data, common.FetchContentDelivery{ + Content: "body", + InlineHint: "retry without --full", + }) + + document := data["document"].(map[string]interface{}) + if data["content_delivery_hint"] != "retry without --full" || document["content_inline"] != true { + t.Fatalf("data = %#v, want inline fallback metadata", data) + } + if document["content"] != "body" { + t.Fatalf("inline fallback changed content: %#v", document) + } + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal inline fallback: %v", err) + } + if strings.Index(string(raw), `"content_delivery_hint"`) > strings.Index(string(raw), `"content":"body"`) { + t.Fatalf("inline hint must precede the oversized body: %s", raw) + } +} + +func decodeDocsSpillEnvelope(t *testing.T, raw []byte) (map[string]interface{}, map[string]interface{}) { + t.Helper() + var envelope map[string]interface{} + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("decode output: %v\nraw=%s", err, raw) + } + data, ok := envelope["data"].(map[string]interface{}) + if !ok { + t.Fatalf("missing data object: %#v", envelope) + } + document, ok := data["document"].(map[string]interface{}) + if !ok { + t.Fatalf("missing document object: %#v", data) + } + return data, document +} + +func assertDocsSpillFile(t *testing.T, document map[string]interface{}, wantContent string) { + t.Helper() + if _, ok := document["content"]; ok { + t.Fatal("spilled document retained inline content") + } + if inline, ok := document["content_inline"].(bool); !ok || inline { + t.Fatalf("content_inline = %#v, want false", document["content_inline"]) + } + file, ok := document["content_file"].(map[string]interface{}) + if !ok { + t.Fatalf("content_file = %#v", document["content_file"]) + } + path, _ := file["path"].(string) + t.Cleanup(func() { _ = os.Remove(path) }) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read spill file: %v", err) + } + if string(got) != wantContent { + t.Fatalf("spill file content mismatch: got %d bytes, want %d", len(got), len(wantContent)) + } + if file["temporary"] != true || int(file["size_bytes"].(float64)) != len(wantContent) { + t.Fatalf("content_file = %#v", file) + } + if hint, _ := file["hint"].(string); !strings.Contains(hint, "Oversized content was saved to temporary file:") || + !strings.Contains(hint, "Consider reading or searching this file locally") { + t.Fatalf("content_file.hint = %q", hint) + } + if preview, _ := document["content_preview"].(string); preview == "" || len(preview) > 512 { + t.Fatalf("content_preview length = %d, want 1..512", len(preview)) + } +} diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index f8b43812e6..c6b10eaeec 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -7,14 +7,19 @@ import ( "context" "fmt" "io" + "math" "strconv" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" ) -const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}` +const ( + docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}` + docsFetchContentJQPath = ".data.document.content" +) // v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path. func v2FetchFlags() []common.Flag { @@ -30,6 +35,11 @@ func v2FetchFlags() []common.Flag { {Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"}, {Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"}, {Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"}, + // Whole-document Markdown pagination with block anchors. + {Name: "full", Type: "bool", Default: "false", Desc: "markdown whole-doc only: return the whole document in one response (disable auto-pagination)"}, + {Name: "page-token", Desc: "markdown whole-doc only: continue a paginated read from a prior next_page_token"}, + {Name: "page-size", Type: "int", Default: "0", Desc: "markdown whole-doc only: per-page token budget hint (0 = server default)"}, + {Name: "embed-max-rows", Type: "int", Default: "50", Desc: "markdown only: cap each rendered table to N data rows (0 = no limit)"}, } } @@ -43,13 +53,56 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error { if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { return err } + if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 0, 0, math.MaxInt32); err != nil { + return err + } if err := validateReadModeFlags(runtime); err != nil { return err } + return validatePaginatedReadFlags(runtime) +} + +// useAnchoredMarkdownRead reports whether the whole-document Markdown read uses the +// paginated anchored-Markdown path instead of the document API. Only Markdown + +// scope=full qualifies; XML, partial scopes, and im-markdown use the document API. +func useAnchoredMarkdownRead(runtime *common.RuntimeContext) bool { + if runtime.Str("doc-format") != "markdown" || effectiveFetchReadMode(runtime) != "full" { + return false + } + // The paginated Markdown API has no field for a historical revision or a cite + // language, so it would silently return the latest revision / default + // language. Route to the document API (which honors both) when + // either is explicitly requested, instead of silently dropping the user's intent. + if runtime.Int("revision-id") > 0 || runtime.Changed("lang") { + return false + } + return true +} + +// validatePaginatedReadFlags checks the paginated-read flags (--full/--page-token/ +// --page-size) apply only to the markdown whole-doc path. +func validatePaginatedReadFlags(runtime *common.RuntimeContext) error { + if runtime.Bool("full") && (strings.TrimSpace(runtime.Str("page-token")) != "" || runtime.Int("page-size") > 0) { + return common.ValidationErrorf("--full cannot be combined with --page-token/--page-size").WithParam("--full") + } + usePaginatedRead := useAnchoredMarkdownRead(runtime) + pagination := runtime.Bool("full") || strings.TrimSpace(runtime.Str("page-token")) != "" || runtime.Int("page-size") > 0 + if pagination && !usePaginatedRead { + // Markdown + full would otherwise enable the paginated read; if it is off here, + // a historical revision or an explicit --lang forced the document API path, so + // the pagination-only flags conflict with those (not with format/scope). + if runtime.Str("doc-format") == "markdown" && effectiveFetchReadMode(runtime) == "full" { + return common.ValidationErrorf("--full/--page-token/--page-size are not supported together with a historical --revision-id (or an explicit --lang), which use the document API path").WithParam("--full") + } + return common.ValidationErrorf("--full/--page-token/--page-size only apply to --doc-format markdown with --scope full").WithParam("--full") + } return nil } func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if useAnchoredMarkdownRead(runtime) { + return dryRunAnchoredMarkdownFetch(runtime) + } // Validate has already accepted --doc; parseDocumentRef cannot fail here. ref, _ := parseDocumentRef(runtime.Str("doc")) body := buildFetchBody(runtime) @@ -61,14 +114,39 @@ func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.Dr Set("document_id", ref.Token) } -func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { +func executeFetchV2(ctx context.Context, runtime *common.RuntimeContext) error { ref, _ := parseDocumentRef(runtime.Str("doc")) + var resolution common.FetchURLResolution + if useAnchoredMarkdownRead(runtime) { + resolution = resolvedFetchURL(runtime) + } + diagnoseWikiType := newWikiFetchTypeGuard(runtime, ref, resolution.WikiProbeAttempted, resolution.WikiNode) + + // Whole-document Markdown reads try the paginated Markdown endpoint first. + // A first-page failure falls back to the document-fetch API, preserving the + // behavior available before the paginated path was introduced. + if useAnchoredMarkdownRead(runtime) { + handled, fetchErr := runAnchoredMarkdownFetch(ctx, runtime, resolution.URL) + if handled { + return fetchErr + } + if redirectErr := diagnoseWikiType(fetchErr); redirectErr != nil { + return redirectErr + } + continuation := contentread.IsPageContinuation(strings.TrimSpace(runtime.Str("page-token"))) + if handled, err := handlePaginatedReadFailure(runtime, continuation, fetchErr); handled || 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 { + if redirectErr := diagnoseWikiType(err); redirectErr != nil { + return redirectErr + } return err } if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil { @@ -81,14 +159,118 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { applyFetchIMMarkdown(data, runtime.Str("doc")) } - 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) + document, ok := data["document"].(map[string]interface{}) + if !ok { + runtime.OutFormatRaw(data, nil, nil) + return nil + } + content, ok := document["content"].(string) + if !ok { + runtime.OutFormatRaw(data, nil, nil) + return nil + } + delivery, scan, err := common.PrepareFetchContentDelivery(runtime, data, content, docsFetchContentJQPath) + if err != nil { + return err + } + emitted := cloneFetchDocumentData(data) + applyFetchContentDelivery(emitted, delivery) + runtime.OutFormatRawWithSafety(emitted, nil, func(w io.Writer) { + common.WriteFetchContentPretty(w, delivery) + }, scan) + return nil +} + +// newWikiFetchTypeGuard lazily resolves a Wiki input only after the Doc read +// has failed. Successful Doc/Docx Wiki reads therefore stay on the fast path +// without an extra get_node call. The probe result is cached so a primary read +// followed by a fallback failure never probes the same node twice. +func newWikiFetchTypeGuard(runtime *common.RuntimeContext, ref documentRef, checked bool, resolvedNode *common.WikiNode) func(error) error { + actualType := "" + actualToken := "" + if resolvedNode != nil { + actualType = strings.TrimSpace(resolvedNode.ObjType) + actualToken = strings.TrimSpace(resolvedNode.ObjToken) + } + + return func(cause error) error { + input := strings.TrimSpace(runtime.Str("doc")) + // Bare tokens are intentionally parsed as docx because their type is + // ambiguous without I/O. After a failed Doc read, treat them as possible + // Wiki node tokens and probe once; a normal docx token simply fails that + // best-effort probe and keeps its original error. + wikiCandidate := ref.Kind == "wiki" || (ref.Kind == "docx" && !strings.Contains(input, "://")) + if !wikiCandidate || !shouldDiagnoseWikiFetchType(cause) { + return nil + } + if !checked { + checked = true + node, err := common.ResolveWikiNode(runtime, ref.Token) + if err != nil { + // Type enrichment is best effort. Permission, transport, and malformed + // get_node responses must not replace the original fetch failure. + return nil //nolint:nilerr // Retain the original fetch failure when the optional Wiki probe fails. } + actualType = strings.TrimSpace(node.ObjType) + actualToken = strings.TrimSpace(node.ObjToken) } - }) - return nil + + switch strings.ToLower(actualType) { + case "", "doc", "docx": + return nil + } + + redirectErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, + "Wiki input resolves to %q, but docs +fetch only supports doc/docx content", actualType). + WithParam("--doc"). + WithHint("%s; do not retry `docs +fetch` for this Wiki resource", + wikiFetchFallbackHint(input, ref, actualType, actualToken)) + if cause != nil { + redirectErr.WithCause(cause) + } + return redirectErr + } +} + +// wikiFetchFallbackHint routes only types drive +fetch actually supports there. +// Mindnote has its own content API; unknown future types get an inspect command +// instead of a remediation that is guaranteed to fail. +func wikiFetchFallbackHint(input string, ref documentRef, actualType, actualToken string) string { + switch strings.ToLower(actualType) { + case "sheet", "sheets", "base", "bitable", "slides", "file", "minutes": + if ref.Kind == "wiki" && strings.Contains(input, "://") { + return fmt.Sprintf("run once: `lark-cli drive +fetch --url %s`", shellQuoteFetchURL(input)) + } + return fmt.Sprintf("run once: `lark-cli drive +fetch --token %s --type wiki`", shellQuoteFetchURL(ref.Token)) + case "mindnote": + return fmt.Sprintf("run: `lark-cli mindnotes nodes list --mindnote-id %s`", shellQuoteFetchURL(actualToken)) + default: + if ref.Kind == "wiki" && strings.Contains(input, "://") { + return fmt.Sprintf("inspect the resource with `lark-cli drive +inspect --url %s` and use its entity-specific reader", shellQuoteFetchURL(input)) + } + return fmt.Sprintf("inspect the resource with `lark-cli drive +inspect --url %s --type wiki` and use its entity-specific reader", shellQuoteFetchURL(ref.Token)) + } +} + +// shouldDiagnoseWikiFetchType limits the extra Wiki probe to failures that can +// plausibly mean "this is not a document": an untyped failure, a malformed +// content response, or an upstream API error. Authentication, permission, +// network, and safety failures keep their original recovery guidance. +func shouldDiagnoseWikiFetchType(cause error) bool { + if cause == nil { + return false + } + if !errs.IsTyped(cause) || errs.IsAPI(cause) { + return true + } + problem, ok := errs.ProblemOf(cause) + return ok && problem.Subtype == errs.SubtypeInvalidResponse +} + +// shellQuoteFetchURL returns a POSIX-shell-safe single argument. The hint is +// intentionally executable and preserves Wiki query/fragment selectors. +func shellQuoteFetchURL(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} { diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 2b3d9fa2c3..e5c9c8d1c0 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" "github.com/spf13/cobra" ) @@ -686,7 +687,9 @@ func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) { func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) { t.Parallel() - for _, format := range []string{"markdown", "im-markdown"} { + // Markdown whole-document reads use the anchored Markdown path; cover the + // document API's detail downgrade through im-markdown. + for _, format := range []string{"im-markdown"} { for _, detail := range []string{"with-ids", "full"} { t.Run(format+"/"+detail, func(t *testing.T) { t.Parallel() @@ -721,26 +724,22 @@ func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) { func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning")) + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning")) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/docs_ai/v1/documents/doxcnFetchWarning/fetch", + URL: contentread.Path, Body: map[string]interface{}{ "code": 0, "msg": "ok", "data": map[string]interface{}{ - "document": map[string]interface{}{ - "document_id": "doxcnFetchWarning", - "revision_id": float64(1), - "content": "# hello", - }, + "full_content": "Body
", + }, + }, + }) + wikiStub := wikiNodeStub("docx", "doxcnBacking") + wikiStub.Optional = true + reg.Register(wikiStub) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/wiki/wikcnDocx", + "--doc-format", "markdown", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(wikiStub.CapturedBodies); got != 0 { + t.Fatalf("wiki get_node calls = %d, want 0 on successful Docx Wiki fast path", got) + } +} + +func TestDocsFetchWikiTypeProbeIsCachedAcrossFallback(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-wiki-probe-cache")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + }) + wikiStub := wikiNodeStub("docx", "doxcnBacking") + wikiStub.Reusable = true + reg.Register(wikiStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/wikcnDocx/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "document API fallback failed", + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/wiki/wikcnDocx", + "--doc-format", "markdown", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("fetch succeeded, want original native API error") + } + if !errs.IsAPI(err) { + t.Fatalf("error type = %T, want original API error: %v", err, err) + } + if got := len(wikiStub.CapturedBodies); got != 1 { + t.Fatalf("wiki get_node calls = %d, want exactly 1 across primary and fallback failures", got) + } + if !strings.Contains(stderr.String(), "falling back to the document API") { + t.Fatalf("stderr missing document API fallback: %q", stderr.String()) + } +} + +func TestDocsFetchBareWikiTokenReusesResolutionForRedirect(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + const wikiToken = "wikcnBareBase" + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-bare-wiki-cache")) + wikiStub := wikiNodeStub("bitable", "basBacking") + wikiStub.Reusable = true + reg.Register(wikiStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", wikiToken, + "--doc-format", "markdown", + "--as", "bot", + }, f, stdout) + assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--doc") + if got := len(wikiStub.CapturedBodies); got != 1 { + t.Fatalf("wiki get_node calls = %d, want exactly 1 across URL resolution and type redirect", got) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, + "lark-cli drive +fetch --token '"+wikiToken+"' --type wiki") { + t.Fatalf("error = %#v, want executable drive +fetch redirect", err) + } +} + +func TestDocsFetchBareTokenDoesNotRepeatFailedWikiProbe(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + const token = "doxcnBareToken" + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-bare-token-failed-wiki-probe")) + wikiStub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Reusable: true, + Status: 404, + } + reg.Register(wikiStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: contentread.Path, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"full_content": ""}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/" + token + "/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "original native failure", + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", token, + "--doc-format", "markdown", + "--as", "bot", + }, f, stdout) + if err == nil || !errs.IsAPI(err) { + t.Fatalf("error = %#v, want original native API failure", err) + } + if got := len(wikiStub.CapturedBodies); got != 1 { + t.Fatalf("wiki get_node calls = %d, want exactly 1 after failed URL resolution", got) + } +} + +func TestDocsFetchWikiProbeFailurePreservesOriginalError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-wiki-probe-failure")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/wikcnUnknown/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "original fetch failure", + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Status: 403, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "https://example.feishu.cn/wiki/wikcnUnknown", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("fetch succeeded, want original API error") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error type = %T, want original *errs.APIError: %v", err, err) + } + if apiErr.Code != 999999 || apiErr.Message != "original fetch failure" { + t.Fatalf("API error = code %d message %q, want original fetch failure", apiErr.Code, apiErr.Message) + } +} + +func TestShouldDiagnoseWikiFetchTypePreservesTypedInfrastructureErrors(t *testing.T) { + t.Parallel() + + if !shouldDiagnoseWikiFetchType(errors.New("read returned no block content")) { + t.Fatal("untyped content failure should trigger Wiki type diagnosis") + } + if !shouldDiagnoseWikiFetchType(errs.NewAPIError(errs.SubtypeUnknown, "document endpoint rejected the resource")) { + t.Fatal("API failure should trigger Wiki type diagnosis") + } + if !shouldDiagnoseWikiFetchType(errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid response")) { + t.Fatal("invalid content response should trigger Wiki type diagnosis") + } + for name, err := range map[string]error{ + "network": errs.NewNetworkError(errs.SubtypeNetworkTransport, "network failed"), + "permission": errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied"), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + if shouldDiagnoseWikiFetchType(err) { + t.Fatalf("%s error should keep its original classification", name) + } + }) + } +} + +func TestShellQuoteFetchURL(t *testing.T) { + t.Parallel() + + const input = "https://example.feishu.cn/wiki/wikcnX?query=a'b&literal=$HOME" + const want = `'https://example.feishu.cn/wiki/wikcnX?query=a'"'"'b&literal=$HOME'` + if got := shellQuoteFetchURL(input); got != want { + t.Fatalf("shellQuoteFetchURL() = %q, want %q", got, want) + } +} + +func wikiNodeStub(objType, objToken string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": objType, + "obj_token": objToken, + "node_token": "wikNode", + }, + }, + }, + } +} + +func assertWikiFetchDriveRedirect(t *testing.T, err error, objType, wikiURL string) { + t.Helper() + + assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--doc") + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if !strings.Contains(validationErr.Message, objType) { + t.Fatalf("message %q does not include actual Wiki type %q", validationErr.Message, objType) + } + for _, want := range []string{ + "lark-cli drive +fetch --url", + shellQuoteFetchURL(wikiURL), + "do not retry `docs +fetch`", + } { + if !strings.Contains(validationErr.Hint, want) { + t.Fatalf("hint %q missing %q", validationErr.Hint, want) + } + } +} diff --git a/shortcuts/doc/docs_skill_doc_test.go b/shortcuts/doc/docs_skill_doc_test.go new file mode 100644 index 0000000000..f210ec1a6c --- /dev/null +++ b/shortcuts/doc/docs_skill_doc_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// docsFrameworkFlags are injected by the common command runner into every +// command (not declared in each command's own Flags list), so they are valid in +// skill examples even though they never appear in v2FetchFlags. +var docsFrameworkFlags = map[string]bool{ + "as": true, "json": true, "dry-run": true, "format": true, + "yes": true, "print-schema": true, "flag-name": true, +} + +func TestSkillDocFetchExampleFlagsAreRegistered(t *testing.T) { + t.Parallel() + data, err := os.ReadFile(filepath.Join("..", "..", "skills", "lark-doc", "references", "lark-doc-fetch.md")) + if err != nil { + t.Skipf("skill doc not found: %v", err) + } + registered := map[string]bool{} + for _, f := range DocsFetch.Flags { + registered[f.Name] = true + } + // Join backslash-continued lines so a multi-line command example is scanned + // as a single command. + joined := strings.ReplaceAll(string(data), "\\\n", " ") + flagRe := regexp.MustCompile(`--[a-z][a-z0-9-]*`) + var bad []string + seen := map[string]bool{} + for _, line := range strings.Split(joined, "\n") { + if !strings.Contains(line, "docs +fetch") { + continue + } + for _, m := range flagRe.FindAllString(line, -1) { + name := strings.TrimPrefix(m, "--") + if registered[name] || docsFrameworkFlags[name] || seen[name] { + continue + } + seen[name] = true + bad = append(bad, name) + } + } + if len(bad) > 0 { + t.Errorf("lark-doc-fetch.md uses flags not registered on docs +fetch: %s\n"+ + "delete a removed flag from the skill doc, or if it is a new framework "+ + "flag add it to docsFrameworkFlags", strings.Join(bad, ", ")) + } +} diff --git a/shortcuts/drive/drive_fetch.go b/shortcuts/drive/drive_fetch.go new file mode 100644 index 0000000000..d20d9a2aac --- /dev/null +++ b/shortcuts/drive/drive_fetch.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" +) + +// DriveFetch reads supported Lark resources as Markdown with a unified output +// envelope. It detects the URL type, unwraps Wiki links, and dispatches to the +// document, content-read, or Minutes API as appropriate. +var DriveFetch = common.Shortcut{ + Service: "drive", + Command: "+fetch", + Description: "Fetch any Lark doc/sheet/base/slides/file/minutes as a readable markdown snapshot (auto-detects type; unwraps wiki)", + Risk: "read", + Scopes: []string{}, + // Each resource type checks scopes at dispatch time. Conditional scopes expose + // complete auth metadata without preflighting unrelated resource types. + ConditionalUserScopes: []string{ + "docx:document:readonly", + "wiki:node:retrieve", + "minutes:minutes.basic:read", + "minutes:minutes.artifacts:read", + "vc:note:read", + }, + ConditionalBotScopes: []string{ + "docx:document:readonly", + "wiki:node:retrieve", + }, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "Lark/Feishu resource URL (docx, doc, sheet, base, wiki, slides, file, minutes)"}, + {Name: "token", Desc: "bare resource token (requires --type)"}, + {Name: "type", Enum: []string{"doc", "docx", "sheet", "sheets", "base", "bitable", "slides", "file", "minutes", "wiki"}, Desc: "resource type (required with --token; auto-detected for --url)"}, + {Name: "embed-max-rows", Type: "int", Default: "50", Desc: "cap each rendered table to N data rows (0 = no limit)"}, + {Name: "full", Type: "bool", Default: "false", Desc: "return the whole resource content in one response (disable auto-pagination; not for minutes)"}, + {Name: "page-token", Desc: "continue a paginated read from a prior next_page_token (not for minutes)"}, + {Name: "page-size", Type: "int", Default: "0", Desc: "per-page token budget hint (0 = server default; not for minutes)"}, + {Name: "include", Desc: "minutes only: comma-separated extras to append: transcript, note-doc"}, + }, + Tips: []string{ + "Unified read entry: pass any Lark doc/sheet/base/slides/file/minutes URL (or --token --type) and get a readable markdown snapshot.", + "For doc deep-read with --scope/--detail use `docs +fetch`; for structured sheet/base data use `sheets +cells-get` / `base +record-list`.", + "Wiki links are unwrapped to the underlying resource and read directly; the originating wiki node is recorded in resource.source.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateFetch(ctx, runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return PlanFetchDryRun(ctx, runtime) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return RunFetch(ctx, runtime) + }, +} + +// RunFetch resolves input, unwraps Wiki, dispatches by type, and emits output. +func RunFetch(ctx context.Context, runtime *common.RuntimeContext) error { + in, err := resolveDriveFetchInput(runtime) + if err != nil { + return err + } + brand := runtime.Config.Brand + + // Unwrap wiki → underlying obj_type/obj_token (and record wiki provenance). + fetchType := in.inputType + fetchToken := in.token + var wikiSrc *fetchSource + if in.inputType == "wiki" { + fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(fetchToken)) + node, werr := common.ResolveWikiNode(runtime, fetchToken) + if werr != nil { + // get_node failed (e.g. the user identity lacks wiki:node:retrieve + // scope, or the node is not found). The fetch service reads the wiki + // URL directly server-side (it unwraps the node itself) and paginates + // by wiki URL + page_token — independent of get_node's obj_type — so + // fall back to direct fetch and honor any --page-token/--full the + // caller passed (fetchWikiDirect surfaces has_more/next_page_token). + fmt.Fprintf(runtime.IO().ErrOut, + "[fetch] wiki get_node failed (%v); falling back to direct fetch of the wiki URL\n", werr) + out, ferr := fetchWikiDirect(ctx, runtime, in) + if ferr != nil { + return ferr + } + res := fetchResource{ + Type: "wiki", + Title: out.title, + Token: in.token, + URL: in.rawURL, + Selector: in.selector, + UpdateTime: out.updateTime, + Source: &fetchSource{Type: "wiki", InputURL: in.rawURL}, + } + return emitDriveFetch(runtime, out, res) + } + objType, ok := normalizeFetchType(node.ObjType) + if !ok { + return common.ValidationErrorf("wiki node resolved to %q, which is not a fetchable resource type", node.ObjType).WithParam("--url") + } + fetchType = objType + fetchToken = node.ObjToken + wikiSrc = &fetchSource{Type: "wiki", InputURL: in.rawURL, NodeToken: node.NodeToken, SpaceID: node.SpaceID} + if err := validateFetchTypeFlags(runtime, fetchType); err != nil { + return err + } + fmt.Fprintf(runtime.IO().ErrOut, "Wiki unwrapped to %s: %s\n", fetchType, common.MaskToken(fetchToken)) + } + + out, err := dispatchDriveFetch(ctx, runtime, in, fetchType, fetchToken, wikiSrc != nil) + if err != nil { + return err + } + + res := fetchResource{ + Type: fetchType, + Title: out.title, + Token: fetchToken, + Selector: in.selector, + UpdateTime: out.updateTime, + CreateTime: out.createTime, + NoteID: out.noteID, + NoteDocToken: out.noteDocToken, + VerbatimDocToken: out.verbatimDocToken, + Source: wikiSrc, + } + res.URL = fetchResourceURL(brand, in, fetchType, fetchToken, wikiSrc != nil) + return emitDriveFetch(runtime, out, res) +} + +func emitDriveFetch(runtime *common.RuntimeContext, out *driveFetchOutput, res fetchResource) error { + warnings := append([]string(nil), out.warnings...) + cursorHint := contentread.PaginationCursorHint(out.hasMore, out.nextToken) + if cursorHint != "" { + warnings = append(warnings, cursorHint) + } + env := newFetchEnvelope(out.content, res). + withPagination(out.hasMore, out.nextToken). + withWarnings(warnings...) + delivery, scan, err := common.PrepareFetchContentDelivery(runtime, env, out.content, ".data.content") + if err != nil { + return err + } + emitted := env.withContentDelivery(delivery) + if cursorHint != "" && runtime.Format != "pretty" { + fmt.Fprintf(runtime.IO().ErrOut, "[fetch] warning: %s\n", cursorHint) + } + runtime.OutFormatRawWithSafety(emitted, nil, func(w io.Writer) { + writeDriveFetchPretty(w, delivery, emitted.Resource, emitted.Warnings) + }, scan) + return nil +} + +func writeDriveFetchPretty(w io.Writer, delivery common.FetchContentDelivery, resource fetchResource, warnings []string) { + common.WriteFetchContentPretty(w, delivery) + if resource.NoteID != "" || resource.NoteDocToken != "" || resource.VerbatimDocToken != "" { + fmt.Fprintln(w, "\nRelated note:") + if resource.NoteID != "" { + fmt.Fprintf(w, " note_id: %s\n", resource.NoteID) + } + if resource.NoteDocToken != "" { + fmt.Fprintf(w, " note_doc_token: %s\n", resource.NoteDocToken) + } + if resource.VerbatimDocToken != "" { + fmt.Fprintf(w, " verbatim_doc_token: %s\n", resource.VerbatimDocToken) + } + } + if len(warnings) > 0 { + fmt.Fprintln(w, "\nWarnings:") + for _, warning := range warnings { + fmt.Fprintf(w, "- %s\n", warning) + } + } +} + +type driveFetchOutput struct { + content string + title string + updateTime int64 + createTime string // minutes only + noteID string // --include note-doc only + noteDocToken string // --include note-doc only + verbatimDocToken string // --include note-doc only + hasMore bool + nextToken string + warnings []string +} diff --git a/shortcuts/drive/drive_fetch_dispatch.go b/shortcuts/drive/drive_fetch_dispatch.go new file mode 100644 index 0000000000..1f92c5cb2d --- /dev/null +++ b/shortcuts/drive/drive_fetch_dispatch.go @@ -0,0 +1,250 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/common/contentread" + "github.com/larksuite/cli/shortcuts/doc" + "github.com/larksuite/cli/shortcuts/minutes" +) + +// dispatchDriveFetch routes a resolved resource type to its reader. +func dispatchDriveFetch(ctx context.Context, runtime *common.RuntimeContext, in driveFetchInput, fetchType, fetchToken string, isWiki bool) (*driveFetchOutput, error) { + forwardURL := fetchResourceURL(runtime.Config.Brand, in, fetchType, fetchToken, isWiki) + maxRows := runtime.Int("embed-max-rows") + + switch fetchType { + case "doc", "docx": + if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil { + return nil, err + } + opts := contentread.FetchOptions{ + MaxRows: maxRows, + Full: runtime.Bool("full"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + PageSize: runtime.Int("page-size"), + } + result, ferr := contentread.FetchAnchoredMarkdown(ctx, runtime, forwardURL, opts) + if ferr != nil { + // A --page-token continuation must not fall back because the document + // API cannot honor a cursor. + if continuationErr := pageContinuationError(runtime, ferr); continuationErr != nil { + return nil, continuationErr + } + content, nerr := doc.FetchDocumentMarkdown(runtime, fetchToken) + if nerr != nil { + return nil, withFetchErrorContext(nerr, + "doc fetch unavailable", + "the paginated Markdown read and document API fallback both failed; check read access for this document") + } + return &driveFetchOutput{ + content: content, + }, nil + } + return &driveFetchOutput{ + content: result.Content, + title: result.Title, + updateTime: result.UpdateTime, + hasMore: result.HasMore, + nextToken: result.NextPageToken, + }, nil + + case "sheet", "bitable", "slides", "file": + // The fetch OpenAPI authorizes every entity type under docx:document:readonly + // (the content-read service's permission model), so the non-document paths + // ensure the same scope. + if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil { + return nil, err + } + opts := contentread.FetchOptions{ + MaxRows: maxRows, + Full: runtime.Bool("full"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + PageSize: runtime.Int("page-size"), + } + res, ferr := contentread.FetchMarkdown(ctx, runtime, forwardURL, fetchType, opts) + if ferr != nil { + if continuationErr := pageContinuationError(runtime, ferr); continuationErr != nil { + return nil, continuationErr + } + return nil, driveFetchUnavailable(fetchType, ferr) + } + return &driveFetchOutput{ + content: res.Content, + title: res.Title, + updateTime: res.UpdateTime, + hasMore: res.HasMore, + nextToken: res.NextPageToken, + }, nil + + case "minutes": + include, _ := minutes.ParseIncludes(runtime.Str("include")) // validated + if err := ensureMinutesScopes(runtime); err != nil { + return nil, err + } + result, merr := minutes.FetchMinutesMarkdown(ctx, runtime, fetchToken, include) + if merr != nil { + return nil, withFetchErrorContext(merr, + "minutes fetch unavailable", + "check the minutes:minutes.basic:read and minutes:minutes.artifacts:read scopes, or use `vc +notes` for the meeting-centric path") + } + return &driveFetchOutput{ + content: result.Content, + title: result.Title, + createTime: result.CreateTime, + noteID: result.NoteID, + noteDocToken: result.NoteDocToken, + verbatimDocToken: result.VerbatimDocToken, + warnings: result.Warnings, + }, nil + } + + return nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported fetch type %q", fetchType) +} + +// ensureMinutesScopes checks only the core metadata and artifact scopes. +// note-doc is optional and performs its own degradable vc:note:read check. +func ensureMinutesScopes(runtime *common.RuntimeContext) error { + return runtime.EnsureScopes([]string{ + "minutes:minutes.basic:read", + "minutes:minutes.artifacts:read", + }) +} + +// fetchResourceURL preserves input URLs and rebuilds URLs for tokens or +// Wiki-unwrapped resources, retaining table and view selectors. +func fetchResourceURL(brand core.LarkBrand, in driveFetchInput, fetchType, fetchToken string, isWiki bool) string { + if isWiki { + return appendQuery(common.BuildResourceURL(brand, fetchType, fetchToken), in.query) + } + if in.isBareToken { + return common.BuildResourceURL(brand, in.inputType, in.token) + } + return in.rawURL +} + +func appendQuery(base, query string) string { + query = strings.TrimSpace(query) + if query == "" { + return base + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + query +} + +// withFetchErrorContext preserves typed metadata and cause while adding path +// context and recovery guidance. Unexpected untyped failures become server +// errors with the original error retained as their cause. +func withFetchErrorContext(err error, label, hint string) error { + if problem, ok := errs.ProblemOf(err); ok && problem != nil { + problem.Message = fmt.Sprintf("%s: %s", label, problem.Message) + if problem.Hint == "" { + problem.Hint = hint + } else if !strings.Contains(problem.Hint, hint) { + problem.Hint += "; " + hint + } + return err + } + return errs.NewAPIError(errs.SubtypeServerError, "%s: %v", label, err). + WithHint(hint). + WithCause(err) +} + +func pageContinuationError(runtime *common.RuntimeContext, cause error) error { + if !contentread.IsPageContinuation(runtime.Str("page-token")) { + return nil + } + return withFetchErrorContext(cause, + "could not read this page", + "the cursor may have expired because the resource changed; re-run without --page-token to read from the start") +} + +// driveFetchUnavailable avoids suggesting a structured reader for access +// denials because it would run as the same identity and fail the same way. +func driveFetchUnavailable(fetchType string, cause error) error { + if fetchAccessDenied(cause) { + return withFetchErrorContext(cause, + fetchType+" not readable by this user", + "confirm you have read access to this resource, or ask its owner to share it (a structured command runs as the same user and will not bypass the denial)") + } + hint := map[string]string{ + "sheet": "use `sheets +cells-get` or `sheets +workbook-info` for structured data", + "bitable": "use `base +record-list` for structured records", + "slides": "slide content is read via fetch only — retry later, or open the deck in Lark/Feishu", + "file": "to download the raw file bytes use `drive +download --file-token
## 快速决策
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
-- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
+- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch`,默认 `detail=simple`;无明确定位的整篇或跨章节阅读用 `docs +fetch --doc-format markdown`(不带 `--scope`);明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md
index 04e1ad1515..fc23f497e4 100644
--- a/skills/lark-doc/references/lark-doc-fetch.md
+++ b/skills/lark-doc/references/lark-doc-fetch.md
@@ -10,6 +10,9 @@ lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
# Markdown 格式
lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown
+# Markdown 整篇读取返回 has_more=true 时续读下一页
+lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown --page-token "` 占位符 |
@@ -94,6 +95,8 @@ metadata:
| 使用图标 | 禁止盲猜 iconType,必须先检索 IconPark,再写 `