From 0c2fd08d5a011062186bfc9a12dd06e0a8f635ca Mon Sep 17 00:00:00 2001 From: SunPeiYang996 Date: Mon, 8 Jun 2026 16:07:52 +0800 Subject: [PATCH] feat:remove docs v1 api (#1291) Change-Id: I29d0af3e5325261f94949d3ab3f65051fb6bd52b --- shortcuts/doc/docs_create.go | 238 +------ shortcuts/doc/docs_create_test.go | 166 +---- shortcuts/doc/docs_create_v2.go | 11 +- shortcuts/doc/docs_fetch.go | 109 +-- shortcuts/doc/docs_fetch_v2.go | 23 +- shortcuts/doc/docs_fetch_v2_test.go | 108 +++ shortcuts/doc/docs_update.go | 263 +------ shortcuts/doc/docs_update_check.go | 281 -------- shortcuts/doc/docs_update_check_test.go | 375 ---------- shortcuts/doc/docs_update_test.go | 250 +++---- shortcuts/doc/docs_update_v2.go | 17 +- shortcuts/doc/markdown_fix.go | 649 ------------------ shortcuts/doc/markdown_fix_hardening_test.go | 287 -------- shortcuts/doc/markdown_fix_test.go | 569 --------------- shortcuts/doc/shortcuts.go | 107 +-- shortcuts/doc/v2_only.go | 103 +++ shortcuts/doc/v2_only_test.go | 86 +++ shortcuts/doc/versioned_help.go | 44 -- shortcuts/doc/versioned_help_test.go | 36 - shortcuts/register_test.go | 235 ++++--- skills/lark-doc/SKILL.md | 3 +- skills/lark-doc/references/lark-doc-create.md | 8 +- skills/lark-doc/references/lark-doc-fetch.md | 5 +- skills/lark-doc/references/lark-doc-md.md | 4 + skills/lark-doc/references/lark-doc-update.md | 8 +- tests/cli_e2e/docs/coverage.md | 7 +- tests/cli_e2e/docs/docs_create_fetch_test.go | 11 +- tests/cli_e2e/docs/docs_update_dryrun_test.go | 113 +-- tests/cli_e2e/docs/docs_update_test.go | 11 +- tests/cli_e2e/docs/helpers_test.go | 8 +- 30 files changed, 737 insertions(+), 3398 deletions(-) delete mode 100644 shortcuts/doc/docs_update_check.go delete mode 100644 shortcuts/doc/docs_update_check_test.go delete mode 100644 shortcuts/doc/markdown_fix.go delete mode 100644 shortcuts/doc/markdown_fix_hardening_test.go delete mode 100644 shortcuts/doc/markdown_fix_test.go create mode 100644 shortcuts/doc/v2_only.go create mode 100644 shortcuts/doc/v2_only_test.go delete mode 100644 shortcuts/doc/versioned_help.go delete mode 100644 shortcuts/doc/versioned_help_test.go diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index 34ee9645..f41f1137 100644 --- a/shortcuts/doc/docs_create.go +++ b/shortcuts/doc/docs_create.go @@ -5,35 +5,13 @@ package doc import ( "context" - "strings" - - "github.com/spf13/cobra" "github.com/larksuite/cli/shortcuts/common" ) -// v1CreateFlags returns the flag definitions for the v1 (MCP) create path. +// v1CreateFlags returns hidden parse-only compatibility flags for old v1 commands. 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") != "" + return docsLegacyFlagDefinitions(docsCreateLegacyFlags()) } var DocsCreate = common.Shortcut{ @@ -43,213 +21,25 @@ var DocsCreate = common.Shortcut{ Risk: "write", AuthTypes: []string{"user", "bot"}, Scopes: []string{"docx:document:create"}, - Tips: docsVersionSelectionTips, + PostMount: installDocsShortcutHelp("+create"), Flags: concatFlags( []common.Flag{ - {Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}}, + docsAPIVersionCompatFlag(), }, - v1CreateFlags(), v2CreateFlags(), + v1CreateFlags(), ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if useV2Create(runtime) { - return validateCreateV2(ctx, runtime) - } - return validateCreateV1(ctx, runtime) + return validateCreateV2(ctx, runtime) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - if useV2Create(runtime) { - return dryRunCreateV2(ctx, runtime) - } - return dryRunCreateV1(ctx, runtime) + return dryRunCreateV2(ctx, runtime) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - if useV2Create(runtime) { - return executeCreateV2(ctx, runtime) - } - return executeCreateV1(ctx, runtime) - }, - PostMount: func(cmd *cobra.Command) { - installVersionedHelp(cmd, "v1", docsCreateFlagVersions) + return executeCreateV2(ctx, runtime) }, } -// ── 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") - // Surface callout type= hint so users know to switch to background-color/ - // border-color when they want a colored callout. Non-blocking, advisory. - if md := runtime.Str("markdown"); md != "" { - WarnCalloutType(md, runtime.IO().ErrOut) - } - 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{} { - md := runtime.Str("markdown") - args := map[string]interface{}{ - "markdown": md, - } - if v := runtime.Str("title"); v != "" { - args["title"] = v - } - if v := runtime.Str("folder-token"); v != "" { - args["folder_token"] = v - } - if v := runtime.Str("wiki-node"); v != "" { - args["wiki_node"] = v - } - if v := runtime.Str("wiki-space"); v != "" { - args["wiki_space"] = v - } - return args -} - -type docsPermissionTarget struct { - Token string - Type string -} - -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 - } - fallbackDocURLV1(runtime, result) -} - -// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP -// response did not include one but did include a doc_id. This protects against -// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult -// drops structured fields. -func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) { - if strings.TrimSpace(common.GetString(result, "doc_url")) != "" { - return - } - docID := strings.TrimSpace(common.GetString(result, "doc_id")) - if docID == "" { - return - } - if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" { - result["doc_url"] = u - } -} - -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"} - } - return docsPermissionTarget{} -} - -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 - case "doc", "docx": - return docsPermissionTarget{Token: ref.Token, Type: ref.Kind}, true - default: - 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", }) @@ -249,148 +248,63 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) { } } -// ── V1 (MCP) tests ── - -func TestDocsCreateV1BotAutoGrantSuccess(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": "文档创建成功", - }) - - permStub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members", - Body: map[string]interface{}{ - "code": 0, - "msg": "ok", - "data": map[string]interface{}{ - "member": map[string]interface{}{ - "member_id": "ou_current_user", - "member_type": "openid", - "perm": "full_access", - }, - }, - }, - } - reg.Register(permStub) - - err := runDocsCreateShortcut(t, f, stdout, []string{ - "+create", - "--title", "项目计划", - "--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.PermissionGrantGranted { - t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) - } -} - -func TestDocsCreateV1WikiSpaceAutoGrantFailure(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/wiki/wikcn_new_node", - "message": "文档创建成功", - }) - - permStub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/permissions/wikcn_new_node/members", - Body: map[string]interface{}{ - "code": 230001, - "msg": "no permission", - }, - } - reg.Register(permStub) - - err := runDocsCreateShortcut(t, f, stdout, []string{ - "+create", - "--markdown", "## 内容", - "--wiki-space", "my_library", - "--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) - } - - 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["perm_type"] != "container" { - t.Fatalf("permission request perm_type = %#v, want %q", body["perm_type"], "container") - } -} - -func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) { +func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) - registerDocsCreateMCPStub(reg, map[string]interface{}{ - "doc_id": "doxcn_new_doc", - "message": "文档创建成功", - // "doc_url" deliberately omitted to exercise the fallback. + 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", "v1", - "--title", "项目计划", - "--markdown", "## 目标", + "--content", "项目计划", "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } - data := decodeDocsCreateEnvelope(t, stdout) - if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want { - t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want) + doc, _ := data["document"].(map[string]interface{}) + if got, want := doc["document_id"], "doxcn_new_doc"; got != want { + t.Fatalf("document.document_id = %#v, want %q", got, want) } } -func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) { +func TestDocsCreateRejectsLegacyV1Flags(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://tenant.feishu.cn/docx/doxcn_new_doc", - "message": "文档创建成功", - }) - + f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", - "--api-version", "v1", "--title", "项目计划", "--markdown", "## 目标", "--as", "user", }) - if err != nil { - t.Fatalf("unexpected error: %v", err) + if err == nil { + t.Fatal("expected legacy v1 flags to be rejected") } - - data := decodeDocsCreateEnvelope(t, stdout) - if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want { - t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want) + for _, want := range []string{ + "docs +create is v2-only", + "the old v1 interface has been shut down", + "legacy v1 flag(s) --title, --markdown are no longer supported", + "--title -> put the title in --content", + "--markdown -> use --content with --doc-format markdown", + "lark-cli skills read lark-doc references/lark-doc-create.md", + "lark-cli skills read lark-doc references/lark-doc-xml.md", + "lark-cli skills read lark-doc references/lark-doc-md.md", + "follow the latest format rules", + "MUST NOT grep/open local SKILL.md files", + "lark-cli docs +create --help", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error missing %q: %v", want, err) + } } } @@ -421,24 +335,6 @@ func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface }) } -func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) { - payload, _ := json.Marshal(result) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/mcp", - Body: map[string]interface{}{ - "result": map[string]interface{}{ - "content": []map[string]interface{}{ - { - "type": "text", - "text": string(payload), - }, - }, - }, - }, - }) -} - func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { t.Helper() diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 68ae824c..70d07ddc 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -13,14 +13,17 @@ import ( // 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}, + {Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, + {Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}}, + {Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"}, + {Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"}, } } func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { + if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil { + return err + } if runtime.Str("content") == "" { return common.FlagErrorf("--content is required") } diff --git a/shortcuts/doc/docs_fetch.go b/shortcuts/doc/docs_fetch.go index f76ec52a..4121d918 100644 --- a/shortcuts/doc/docs_fetch.go +++ b/shortcuts/doc/docs_fetch.go @@ -5,40 +5,13 @@ package doc import ( "context" - "fmt" - "io" - "strconv" - - "github.com/spf13/cobra" "github.com/larksuite/cli/shortcuts/common" ) -// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path. +// v1FetchFlags returns hidden parse-only compatibility flags for old v1 commands. 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 the -// presence of any v2-only flag on the command line — we check pflag.Changed -// rather than the value so that explicitly typing `--detail simple` (equal -// to the default) still routes to v2. -func useV2Fetch(runtime *common.RuntimeContext) bool { - if runtime.Str("api-version") == "v2" { - return true - } - for _, name := range []string{"detail", "doc-format", "scope", "revision-id", "start-block-id", "end-block-id", "keyword", "context-before", "context-after", "max-depth"} { - if runtime.Changed(name) { - return true - } - } - return false + return docsLegacyFlagDefinitions(docsFetchLegacyFlags()) } var DocsFetch = common.Shortcut{ @@ -49,88 +22,22 @@ var DocsFetch = common.Shortcut{ Scopes: []string{"docx:document:readonly"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Tips: docsVersionSelectionTips, + PostMount: installDocsShortcutHelp("+fetch"), Flags: concatFlags( []common.Flag{ - {Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}}, + docsAPIVersionCompatFlag(), {Name: "doc", Desc: "document URL or token", Required: true}, }, - v1FetchFlags(), v2FetchFlags(), + v1FetchFlags(), ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if useV2Fetch(runtime) { - return validateFetchV2(ctx, runtime) - } - return nil + return validateFetchV2(ctx, runtime) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - if useV2Fetch(runtime) { - return dryRunFetchV2(ctx, runtime) - } - return dryRunFetchV1(ctx, runtime) + return dryRunFetchV2(ctx, runtime) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - if useV2Fetch(runtime) { - return executeFetchV2(ctx, runtime) - } - return executeFetchV1(ctx, runtime) - }, - PostMount: func(cmd *cobra.Command) { - installVersionedHelp(cmd, "v1", docsFetchFlagVersions) + return executeFetchV2(ctx, runtime) }, } - -// ── 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 index 52885fae..305847a2 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -16,16 +16,16 @@ import ( // 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"}}, - {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: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"}, - {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"}, + {Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}}, + {Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}}, + {Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"}, + {Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}}, + {Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"}, + {Name: "end-block-id", Desc: "range end block id; -1 means through document end"}, + {Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"}, + {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"}, } } @@ -33,6 +33,9 @@ func v2FetchFlags() []common.Flag { // --dry-run so that invalid input fails with a structured exit code (2) and // JSON envelope instead of slipping through dry-run as a "success". func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error { + if err := validateDocsV2Only(runtime, "+fetch", docsFetchLegacyFlags()); err != nil { + return err + } if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { return common.FlagErrorf("invalid --doc: %v", err) } diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 5a3294c9..2327539d 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -5,6 +5,7 @@ package doc import ( "context" + "strings" "testing" "github.com/larksuite/cli/shortcuts/common" @@ -58,6 +59,82 @@ func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) { } } +func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) { + t.Parallel() + + runtime := newFetchShortcutTestRuntime(t, "", nil) + if err := validateFetchV2(context.Background(), runtime); err != nil { + t.Fatalf("validateFetchV2() error = %v", err) + } + + dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime)) + if len(dry.API) != 1 { + t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API)) + } + if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnFetchDryRun/fetch"; got != want { + t.Fatalf("dry-run URL = %q, want %q", got, want) + } + if got, want := dry.API[0].Body["format"], "xml"; got != want { + t.Fatalf("dry-run format = %#v, want %q", got, want) + } +} + +func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) { + t.Parallel() + + runtime := newFetchShortcutTestRuntime(t, "v1", nil) + if err := validateFetchV2(context.Background(), runtime); err != nil { + t.Fatalf("validateFetchV2() error = %v", err) + } + + dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime)) + if len(dry.API) != 1 { + t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API)) + } + if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnFetchDryRun/fetch"; got != want { + t.Fatalf("dry-run URL = %q, want %q", got, want) + } +} + +func TestDocsFetchRejectsLegacyFlags(t *testing.T) { + tests := []struct { + name string + setFlags map[string]string + want []string + }{ + { + name: "legacy offset", + setFlags: map[string]string{"offset": "10"}, + want: []string{ + "docs +fetch is v2-only", + "the old v1 interface has been shut down", + "legacy v1 flag(s) --offset are no longer supported", + "--offset -> use --scope outline/range/keyword/section", + "lark-cli skills read lark-doc references/lark-doc-fetch.md", + "MUST NOT grep/open local SKILL.md files", + "lark-cli docs +fetch --help", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags) + err := validateFetchV2(context.Background(), runtime) + if err == nil { + t.Fatal("expected v2-only validation error") + } + for _, want := range tt.want { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error missing %q: %v", want, err) + } + } + }) + } +} + func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext { cmd := &cobra.Command{Use: "+fetch"} cmd.Flags().String("doc-format", "xml", "") @@ -73,6 +150,37 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext { return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil) } +func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "+fetch"} + cmd.Flags().String("api-version", "", "") + cmd.Flags().String("doc", "doxcnFetchDryRun", "") + cmd.Flags().String("doc-format", "xml", "") + cmd.Flags().String("detail", "simple", "") + cmd.Flags().Int("revision-id", -1, "") + cmd.Flags().String("scope", "full", "") + cmd.Flags().String("start-block-id", "", "") + cmd.Flags().String("end-block-id", "", "") + cmd.Flags().String("keyword", "", "") + cmd.Flags().Int("context-before", 0, "") + cmd.Flags().Int("context-after", 0, "") + cmd.Flags().Int("max-depth", -1, "") + cmd.Flags().String("offset", "", "") + cmd.Flags().String("limit", "", "") + if apiVersion != "" { + if err := cmd.Flags().Set("api-version", apiVersion); err != nil { + t.Fatalf("set api-version: %v", err) + } + } + for name, value := range setFlags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } + } + return common.TestNewRuntimeContext(cmd, nil) +} + func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext { cmd := &cobra.Command{Use: "+create"} cmd.Flags().String("doc-format", "xml", "") diff --git a/shortcuts/doc/docs_update.go b/shortcuts/doc/docs_update.go index f8753cad..3968c807 100644 --- a/shortcuts/doc/docs_update.go +++ b/shortcuts/doc/docs_update.go @@ -5,57 +5,13 @@ package doc import ( "context" - "fmt" - "regexp" - "strings" - - "github.com/spf13/cobra" "github.com/larksuite/cli/shortcuts/common" ) -var validModesV1 = map[string]bool{ - "append": true, - "overwrite": true, - "replace_range": true, - "replace_all": true, - "insert_before": true, - "insert_after": true, - "delete_range": true, -} - -var needsSelectionV1 = map[string]bool{ - "replace_range": true, - "replace_all": true, - "insert_before": true, - "insert_after": true, - "delete_range": true, -} - -// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path. +// v1UpdateFlags returns hidden parse-only compatibility flags for old v1 commands. 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") != "" + return docsLegacyFlagDefinitions(docsUpdateLegacyFlags()) } var DocsUpdate = common.Shortcut{ @@ -65,225 +21,22 @@ var DocsUpdate = common.Shortcut{ Risk: "write", Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, AuthTypes: []string{"user", "bot"}, - Tips: docsVersionSelectionTips, + PostMount: installDocsShortcutHelp("+update"), Flags: concatFlags( []common.Flag{ - {Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}}, + docsAPIVersionCompatFlag(), {Name: "doc", Desc: "document URL or token", Required: true}, }, - v1UpdateFlags(), v2UpdateFlags(), + v1UpdateFlags(), ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if useV2Update(runtime) { - return validateUpdateV2(ctx, runtime) - } - return validateUpdateV1(ctx, runtime) + return validateUpdateV2(ctx, runtime) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - if useV2Update(runtime) { - return dryRunUpdateV2(ctx, runtime) - } - return dryRunUpdateV1(ctx, runtime) + return dryRunUpdateV2(ctx, runtime) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - if useV2Update(runtime) { - return executeUpdateV2(ctx, runtime) - } - return executeUpdateV1(ctx, runtime) - }, - PostMount: func(cmd *cobra.Command) { - installVersionedHelp(cmd, "v1", docsUpdateFlagVersions) + return executeUpdateV2(ctx, runtime) }, } - -// ── V1 (MCP) implementation ── - -func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error { - mode := runtime.Str("mode") - if mode == "" { - return common.FlagErrorf("--mode is required") - } - 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(selectionRequiredMessageV1(mode)) - } - if err := validateSelectionByTitleV1(selTitle); err != nil { - return err - } - - return nil -} - -func selectionRequiredMessageV1(mode string) string { - msg := fmt.Sprintf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode) - if mode == "replace_all" { - msg += ". If you intended to replace the entire document body, use --mode overwrite instead." - } - return msg -} - -func validateSelectionByTitleV1(title string) error { - if title == "" { - return nil - } - trimmed := strings.TrimSpace(title) - if strings.Contains(trimmed, "\n") || strings.Contains(trimmed, "\r") { - return common.FlagErrorf("--selection-by-title must be a single heading line (for example: '## Section')") - } - if strings.HasPrefix(trimmed, "#") { - return nil - } - return common.FlagErrorf("--selection-by-title must include markdown heading prefix '#'. Example: --selection-by-title '## Section'") -} - -func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - args := buildUpdateArgsV1(runtime) - return common.NewDryRunAPI(). - POST(common.MCPEndpoint(runtime.Config.Brand)). - Desc("MCP tool: update-doc"). - Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}). - Set("mcp_tool", "update-doc").Set("args", args) -} - -func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error { - warnDeprecatedV1(runtime, "+update") - - // Static semantic checks run before the MCP call so users see - // warnings even if the subsequent request fails. They never block - // execution — the update still proceeds. - for _, w := range docsUpdateWarnings(runtime.Str("mode"), runtime.Str("markdown")) { - fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w) - } - - // Overwrite replaces the entire document, silently discarding any - // whiteboard or file-attachment blocks that cannot be re-created from - // Markdown. Pre-fetch the current content and warn when such blocks - // are present so the caller can take a backup before proceeding. - if runtime.Str("mode") == "overwrite" { - if w := warnOverwriteResourceBlocks(runtime); w != "" { - fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w) - } - } - - // Surface callout type= hint so users know to switch to background-color/ - // border-color when they want a colored callout. Non-blocking, advisory. - if md := runtime.Str("markdown"); md != "" { - WarnCalloutType(md, runtime.IO().ErrOut) - } - - args := buildUpdateArgsV1(runtime) - - result, err := common.CallMCPTool(runtime, "update-doc", args) - if err != nil { - return err - } - - normalizeWhiteboardResult(result, runtime.Str("markdown")) - runtime.Out(result, nil) - return nil -} - -func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} { - args := map[string]interface{}{ - "doc_id": runtime.Str("doc"), - "mode": runtime.Str("mode"), - } - if v := runtime.Str("markdown"); v != "" { - args["markdown"] = v - } - if v := runtime.Str("selection-with-ellipsis"); v != "" { - args["selection_with_ellipsis"] = v - } - if v := runtime.Str("selection-by-title"); v != "" { - args["selection_by_title"] = v - } - if v := runtime.Str("new-title"); v != "" { - args["new_title"] = v - } - return args -} - -// resourceBlockRe matches the opening of a or tag -// (followed by whitespace, > or /) to avoid false positives on tag names like -// or prose that merely mentions the word "whiteboard". -var resourceBlockRe = regexp.MustCompile(`<(whiteboard|file)[\s/>]`) - -// warnOverwriteResourceBlocks pre-fetches the current document and returns a -// non-empty warning string when the document contains whiteboard or file -// attachment blocks that would be permanently deleted by an overwrite. Returns -// an empty string (no warning) when the document is clean or the fetch fails -// (we never block the overwrite on a best-effort check). -// -// This function is not unit-tested because it depends on an external MCP call -// (fetch-doc). The pure detection logic lives in checkOverwriteResourceBlocks, -// which has full table-driven coverage. -// -// Performance: this adds one extra fetch-doc round-trip to every --mode overwrite -// call, even when the document has no resource blocks. The cost is intentional: -// the guard is best-effort and silent on failure, so the latency is bounded and -// the trade-off is acceptable to avoid silent data loss. -func warnOverwriteResourceBlocks(runtime *common.RuntimeContext) string { - args := map[string]interface{}{ - "doc_id": runtime.Str("doc"), - // skip_task_detail reduces response payload by omitting per-block task - // metadata, making the pre-fetch faster and cheaper. - "skip_task_detail": true, - } - result, err := common.CallMCPTool(runtime, "fetch-doc", args) - if err != nil { - // Fetch failed — silently skip the guard rather than blocking overwrite. - return "" - } - md, _ := result["markdown"].(string) - return checkOverwriteResourceBlocks(md) -} - -// checkOverwriteResourceBlocks scans Markdown for resource block tags that -// cannot survive an overwrite: and . Returns a -// warning string listing the counts if any are found, empty string otherwise. -func checkOverwriteResourceBlocks(markdown string) string { - matches := resourceBlockRe.FindAllStringSubmatch(markdown, -1) - whiteboards, files := 0, 0 - for _, m := range matches { - switch m[1] { - case "whiteboard": - whiteboards++ - case "file": - files++ - } - } - var found []string - if whiteboards == 1 { - found = append(found, "1 whiteboard block") - } else if whiteboards > 1 { - found = append(found, fmt.Sprintf("%d whiteboard blocks", whiteboards)) - } - if files == 1 { - found = append(found, "1 file attachment block") - } else if files > 1 { - found = append(found, fmt.Sprintf("%d file attachment blocks", files)) - } - if len(found) == 0 { - return "" - } - return fmt.Sprintf( - "the document contains %s that cannot be reconstructed from Markdown; "+ - "overwrite will permanently delete them. "+ - "Consider fetching a backup with `docs +fetch` before overwriting.", - strings.Join(found, " and "), - ) -} diff --git a/shortcuts/doc/docs_update_check.go b/shortcuts/doc/docs_update_check.go deleted file mode 100644 index cf71c101..00000000 --- a/shortcuts/doc/docs_update_check.go +++ /dev/null @@ -1,281 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package doc - -import ( - "regexp" - "strings" -) - -// docsUpdateWarnings returns a list of human-readable warnings for a -// `docs +update` invocation based on static analysis of the mode and -// Markdown payload. The warnings describe CLI/MCP contract edges that -// commonly surprise users; the update is still executed — callers -// decide whether to stop at a warning. -// -// Both checks ignore fenced code blocks (```…``` and ~~~…~~~, with up -// to 3 leading spaces per CommonMark §4.5), inline code spans, and -// backslash-escaped emphasis markers so that literal Markdown content -// embedded in code samples or escaped prose does not produce false -// positives. -// -// Warnings emitted (current): -// -// 1. replace_* modes do not split blocks. A Markdown payload containing -// a blank line (\n\n) in prose implies the caller expects multiple -// paragraphs, but replace_range / replace_all only swap in-block -// text. The resulting block will contain the blank line as literal -// text and appear as a single paragraph in the UI. -// -// 2. Lark does not round-trip bold+italic. Six shapes are detected: -// ***text*** ___text___ -// **_text_** __*text*__ -// _**text**_ *__text__* -// Lark stores only one of the two emphases (usually italic), silently -// dropping the other. The user wanted both; they will get one. -func docsUpdateWarnings(mode, markdown string) []string { - var warnings []string - if w := checkDocsUpdateReplaceMultilineMarkdown(mode, markdown); w != "" { - warnings = append(warnings, w) - } - if w := checkDocsUpdateBoldItalic(markdown); w != "" { - warnings = append(warnings, w) - } - return warnings -} - -// checkDocsUpdateReplaceMultilineMarkdown flags markdown that contains a -// blank-line paragraph break outside fenced code blocks under a replace_* -// mode. Blank lines inside code fences are literal content and don't -// imply paragraph semantics, so they are deliberately ignored. -func checkDocsUpdateReplaceMultilineMarkdown(mode, markdown string) string { - if mode != "replace_range" && mode != "replace_all" { - return "" - } - // A CR/LF-robust check: both "\n\n" and "\r\n\r\n" count as paragraph - // separators. We normalize line endings once before detection. - normalized := strings.ReplaceAll(markdown, "\r\n", "\n") - if !proseHasBlankLine(normalized) { - return "" - } - return "--mode=" + mode + " does not split a block into multiple paragraphs; " + - "the blank line in --markdown will render as literal text. " + - "For multiple paragraphs, use --mode=delete_range followed by --mode=insert_before." -} - -// combinedEmphasisPatterns holds the six documented combined-emphasis shapes -// that Lark downgrades to a single emphasis. Each entry pairs a regex with a -// short shape label for the warning message. The two forms per shape (with -// and without `[^…]*?`) are there because the lazy quantifier needs at least -// one non-delimiter character to match; single-rune payloads (e.g. `***X***`) -// take the second alternation. -var combinedEmphasisPatterns = []struct { - shape string - re *regexp.Regexp -}{ - // Bold+italic with a single delimiter char. - {"***text***", regexp.MustCompile(`\*\*\*\S[^*]*?\S\*\*\*|\*\*\*\S\*\*\*`)}, - {"___text___", regexp.MustCompile(`___\S[^_]*?\S___|___\S___`)}, - - // Bold wrapping italic (asterisk outside). - {"**_text_**", regexp.MustCompile(`\*\*_\S[^_*]*?\S_\*\*|\*\*_\S_\*\*`)}, - {"__*text*__", regexp.MustCompile(`__\*\S[^_*]*?\S\*__|__\*\S\*__`)}, - - // Italic wrapping bold (asterisk inside). - {"_**text**_", regexp.MustCompile(`_\*\*\S[^_*]*?\S\*\*_|_\*\*\S\*\*_`)}, - {"*__text__*", regexp.MustCompile(`\*__\S[^_*]*?\S__\*|\*__\S__\*`)}, -} - -// checkDocsUpdateBoldItalic flags Markdown emphases that attempt to -// combine bold and italic in a way Lark cannot represent. Fenced code -// blocks, inline code spans, and backslash-escaped emphasis markers are -// stripped first so that literal markdown examples ("here is a -// `***keyword***` to flag") do not trigger the warning. -func checkDocsUpdateBoldItalic(markdown string) string { - if markdown == "" { - return "" - } - sanitized := stripEscapedEmphasisMarkers(stripMarkdownCodeRegions(markdown)) - for _, p := range combinedEmphasisPatterns { - if p.re.MatchString(sanitized) { - return "Lark does not support combined bold+italic markers " + - "(e.g. ***text***, ___text___, **_text_**, _**text**_, __*text*__, *__text__*); " + - "the emphasis will be downgraded to either bold or italic. " + - "Split into two separate emphases or drop one of them." - } - } - return "" -} - -// proseHasBlankLine reports whether markdown contains a blank line outside -// of fenced code blocks. Blank lines inside ```...``` or ~~~...~~~ fences -// are code content, not paragraph separators, and must not trip the -// "replace_* cannot split paragraphs" warning. -// -// A blank line counts only when it sits between two non-blank boundaries -// (other prose, or a fence open/close). A trailing empty line at EOF is -// not treated as "\n\n". -func proseHasBlankLine(markdown string) bool { - lines := strings.Split(markdown, "\n") - inFence := false - var fenceMarker string - for i, line := range lines { - if inFence { - if isCodeFenceClose(line, fenceMarker) { - inFence = false - fenceMarker = "" - } - continue - } - if marker := codeFenceOpenMarker(line); marker != "" { - inFence = true - fenceMarker = marker - continue - } - if strings.TrimSpace(line) == "" && i > 0 && i+1 < len(lines) { - return true - } - } - return false -} - -// stripMarkdownCodeRegions returns markdown with fenced code blocks blanked -// out and inline code spans replaced by whitespace of equivalent length. -// Byte offsets outside the masked regions are preserved, so follow-on -// regex matches still point at real prose positions. -func stripMarkdownCodeRegions(markdown string) string { - lines := strings.Split(markdown, "\n") - inFence := false - var fenceMarker string - for i, line := range lines { - if inFence { - if isCodeFenceClose(line, fenceMarker) { - inFence = false - fenceMarker = "" - } - lines[i] = "" - continue - } - if marker := codeFenceOpenMarker(line); marker != "" { - inFence = true - fenceMarker = marker - lines[i] = "" - continue - } - lines[i] = maskInlineCodeSpans(line) - } - return strings.Join(lines, "\n") -} - -// maskInlineCodeSpans replaces the byte ranges of any inline code spans in -// line with space characters of equal length. Uses scanInlineCodeSpans from -// markdown_fix.go, which implements the CommonMark §6.1 matching-backtick-run -// rule (so “ `a`b` “ is a single span). -func maskInlineCodeSpans(line string) string { - spans := scanInlineCodeSpans(line) - if len(spans) == 0 { - return line - } - var sb strings.Builder - pos := 0 - for _, loc := range spans { - sb.WriteString(line[pos:loc[0]]) - sb.WriteString(strings.Repeat(" ", loc[1]-loc[0])) - pos = loc[1] - } - sb.WriteString(line[pos:]) - return sb.String() -} - -// stripEscapedEmphasisMarkers removes backslash-escaped '*' and '_' so the -// bold/italic regexes don't treat literal sequences like `\***text***` as -// real combined emphasis. CommonMark renders "\*" as a literal "*" with no -// emphasis semantics; dropping the escape + its target from the detection -// input keeps the heuristic aligned with what the renderer actually does. -// -// Known limitation: a doubled backslash escape ("\\" followed by a real -// emphasis marker, e.g. `\\***text***`) renders as a literal backslash -// followed by genuine combined emphasis, but this strip is not a proper -// parser and will instead consume the second backslash as the opener for -// another escape. That hides the real emphasis from the check, producing -// a false negative. Practical impact is small (this shape is rare in the -// kind of AI-Agent prompts we target) and the alternative — a full -// CommonMark escape parser — is not worth the code surface here. -func stripEscapedEmphasisMarkers(s string) string { - s = strings.ReplaceAll(s, `\*`, "") - s = strings.ReplaceAll(s, `\_`, "") - return s -} - -// codeFenceOpenMarker returns the fence marker (e.g. "```" or "~~~~") if -// line opens a fenced code block, otherwise "". Applies CommonMark §4.5 -// rules: up to 3 leading spaces are tolerated; 4+ leading spaces (or any -// leading tab, which expands to 4 columns) make the line an indented code -// block rather than a fence. -func codeFenceOpenMarker(line string) string { - body, ok := fenceIndentOK(line) - if !ok { - return "" - } - switch { - case strings.HasPrefix(body, "```"): - return leadingRun(body, '`') - case strings.HasPrefix(body, "~~~"): - return leadingRun(body, '~') - } - return "" -} - -// isCodeFenceClose reports whether line closes a fence opened with marker. -// Per CommonMark §4.5 the closer must use the same fence character, be at -// least as long as the opener, sit within 0..3 leading spaces, and carry -// no info-string text. -func isCodeFenceClose(line, marker string) bool { - if marker == "" { - return false - } - body, ok := fenceIndentOK(line) - if !ok { - return false - } - fenceChar := marker[0] - run := leadingRun(body, fenceChar) - if len(run) < len(marker) { - return false - } - return strings.TrimSpace(body[len(run):]) == "" -} - -// fenceIndentOK returns (bodyWithoutLeadingSpaces, true) when line has -// 0..3 leading spaces and no leading tab — i.e. the indentation is -// permissible for a CommonMark fence. Returns ("", false) otherwise -// (4+ leading spaces or any tab), meaning the line must be treated as -// indented code block content rather than a fence boundary. -func fenceIndentOK(line string) (string, bool) { - for i := 0; i < len(line) && i < 4; i++ { - switch line[i] { - case ' ': - continue - case '\t': - return "", false - default: - return line[i:], true - } - } - // Reached index 4 without hitting a non-space character: too indented. - if len(line) >= 4 { - return "", false - } - // Line shorter than 4 chars and all spaces — still valid (empty content). - return "", true -} - -// leadingRun returns the longest prefix of s made up of the byte c. -func leadingRun(s string, c byte) string { - i := 0 - for i < len(s) && s[i] == c { - i++ - } - return s[:i] -} diff --git a/shortcuts/doc/docs_update_check_test.go b/shortcuts/doc/docs_update_check_test.go deleted file mode 100644 index 50905873..00000000 --- a/shortcuts/doc/docs_update_check_test.go +++ /dev/null @@ -1,375 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package doc - -import ( - "strings" - "testing" -) - -func TestCheckDocsUpdateReplaceMultilineMarkdown(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mode string - markdown string - wantHint bool - }{ - { - name: "replace_range with blank line emits hint", - mode: "replace_range", - markdown: "new paragraph\n\nsecond paragraph", - wantHint: true, - }, - { - name: "replace_all with blank line emits hint", - mode: "replace_all", - markdown: "first\n\nsecond", - wantHint: true, - }, - { - name: "replace_range single paragraph is fine", - mode: "replace_range", - markdown: "just a single paragraph of text", - wantHint: false, - }, - { - name: "single newline is not a paragraph break", - mode: "replace_range", - markdown: "line one\nline two", - wantHint: false, - }, - { - name: "crlf paragraph break is also detected", - mode: "replace_range", - markdown: "first\r\n\r\nsecond", - wantHint: true, - }, - { - name: "other modes are not flagged", - mode: "insert_before", - markdown: "first\n\nsecond", - wantHint: false, - }, - { - name: "append mode is not flagged", - mode: "append", - markdown: "first\n\nsecond", - wantHint: false, - }, - { - name: "empty markdown is fine", - mode: "replace_range", - markdown: "", - wantHint: false, - }, - { - // The check must ignore blank lines inside fenced code; otherwise - // a user replacing one block with a legitimate code sample that - // contains blank lines would see a spurious warning. - name: "blank line inside backtick fenced code is not flagged", - mode: "replace_range", - markdown: "```\nline1\n\nline2\n```", - wantHint: false, - }, - { - name: "blank line inside tilde fenced code is not flagged", - mode: "replace_range", - markdown: "~~~\ncode line one\n\ncode line two\n~~~", - wantHint: false, - }, - { - // Mixed prose + fenced code: any blank line in prose still wins, - // even if the fenced content also contains blanks. - name: "blank line in prose outside fence still flags even when fence has blanks", - mode: "replace_range", - markdown: "first paragraph\n\nsecond paragraph\n\n```\ncode\n\nmore\n```", - wantHint: true, - }, - { - // Fenced code with no blank lines inside must not trip on the - // fence markers themselves. - name: "fenced code with no blank lines does not flag", - mode: "replace_range", - markdown: "prose before\n```go\nfmt.Println(\"hi\")\n```\nprose after", - wantHint: false, - }, - { - // CommonMark §4.5: the closing fence must be ≥ opening fence length. - // A 4-backtick close for a 3-backtick open is a legitimate way to - // embed triple-backticks in a code sample; the check must see the - // fence as properly closed and not treat the rest of the document - // as still-inside-fence. - name: "longer close marker closes fence correctly", - mode: "replace_range", - markdown: "```\nsome code\n````\n\nprose paragraph after", - wantHint: true, // the blank line AFTER the fence is real prose - }, - { - name: "longer close marker still hides blank line inside fence", - mode: "replace_range", - markdown: "```\nbefore\n\nafter\n````", - wantHint: false, - }, - { - // 4+ leading spaces make the line an indented code block, not a - // fence open. The "fence"-looking line is code content; the - // surrounding blank must still be detected. - name: "four-space indented fence-like line is not a fence open", - mode: "replace_range", - markdown: "first paragraph\n\n ```\n code\n ```", - wantHint: true, - }, - { - // A tab in the leading whitespace is always ≥4 columns and thus - // forces indented-code-block semantics. - name: "tab-indented fence-like line is not a fence open", - mode: "replace_range", - markdown: "first paragraph\n\n\t```\n\tcode\n\t```", - wantHint: true, - }, - { - // 3 leading spaces is still within the fence-tolerance window. - name: "three-space indented fence is still a fence", - mode: "replace_range", - markdown: " ```\ncode\n\nmore\n ```", - wantHint: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := checkDocsUpdateReplaceMultilineMarkdown(tt.mode, tt.markdown) - hasHint := got != "" - if hasHint != tt.wantHint { - t.Fatalf("checkDocsUpdateReplaceMultilineMarkdown(%q, %q) = %q, wantHint=%v", - tt.mode, tt.markdown, got, tt.wantHint) - } - if tt.wantHint && (!strings.Contains(got, "delete_range") || !strings.Contains(got, "insert_before")) { - t.Errorf("hint should suggest delete_range/insert_before remediation, got: %s", got) - } - }) - } -} - -func TestCheckDocsUpdateBoldItalic(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - wantHint bool - }{ - { - name: "triple asterisks flagged", - input: "a ***key insight*** here", - wantHint: true, - }, - { - name: "triple asterisks single char flagged", - input: "a ***X*** here", - wantHint: true, - }, - { - name: "bold wrapping underscore italic flagged", - input: "note: **_important_** detail", - wantHint: true, - }, - { - name: "underscore wrapping double asterisk flagged", - input: "note: _**important**_ detail", - wantHint: true, - }, - { - name: "plain bold is fine", - input: "this is **bold** text", - wantHint: false, - }, - { - name: "plain italic is fine", - input: "this is *italic* or _italic_ text", - wantHint: false, - }, - { - name: "horizontal rule is not flagged", - input: "paragraph\n\n---\n\nnext", - wantHint: false, - }, - { - name: "bold followed by italic with space is not flagged", - input: "**bold** and *italic*", - wantHint: false, - }, - { - name: "empty input is fine", - input: "", - wantHint: false, - }, - { - // The emphasis check must not fire on literal Markdown samples - // inside a fenced code block — the canonical use case is docs - // authors pasting tutorials that demonstrate these exact patterns. - name: "triple asterisks inside backtick fenced code is not flagged", - input: "example:\n```\nthe shape ***keyword*** downgrades\n```", - wantHint: false, - }, - { - name: "underscore-bold inside fenced code is not flagged", - input: "example:\n```markdown\nuse **_strong italic_** carefully\n```", - wantHint: false, - }, - { - name: "bold-underscore inside fenced code is not flagged", - input: "example:\n~~~\n_**outside-underscore**_ is a bad shape\n~~~", - wantHint: false, - }, - { - name: "triple asterisks inside inline code span is not flagged", - input: "the literal `***text***` marker is just a sample", - wantHint: false, - }, - { - name: "underscore-bold inside inline code is not flagged", - input: "the shape `**_italic_**` would downgrade, but only if it were real", - wantHint: false, - }, - { - name: "escaped triple asterisks rendered as literal text is not flagged", - input: `the literal \***text*** with escaped opener`, - wantHint: false, - }, - { - name: "escaped bold inside underscore-italic is not flagged", - input: `shape \*\*_text_\*\* is literal, not emphasis`, - wantHint: false, - }, - { - // Real emphasis outside the code span must still be detected — - // the strip step must not over-sanitize. - name: "real triple asterisks outside inline code still flags", - input: "real ***strong*** and literal `***keyword***` — the first one counts", - wantHint: true, - }, - { - name: "real triple asterisks outside fenced code still flags", - input: "real ***strong***\n\n```\nliteral ***keyword*** in code\n```", - wantHint: true, - }, - // --- Triple-underscore combined emphasis: ___text___ --- - { - name: "triple underscores flagged", - input: "a ___key insight___ here", - wantHint: true, - }, - { - name: "triple underscores single char flagged", - input: "a ___X___ here", - wantHint: true, - }, - { - name: "triple underscores inside fenced code not flagged", - input: "sample:\n```\nuse ___keyword___ carefully\n```", - wantHint: false, - }, - { - name: "triple underscores inside inline code not flagged", - input: "the literal `___phrase___` marker", - wantHint: false, - }, - { - name: "escaped triple underscores not flagged", - input: `literal \___phrase___ with escaped opener`, - wantHint: false, - }, - // --- Underscore-bold wrapping asterisk-italic: __*text*__ --- - { - name: "underscore-bold wrapping asterisk-italic flagged", - input: "note: __*important*__ text", - wantHint: true, - }, - { - name: "underscore-bold wrapping asterisk-italic inside fenced code not flagged", - input: "```\nnote: __*important*__ sample\n```", - wantHint: false, - }, - { - name: "underscore-bold wrapping asterisk-italic inside inline code not flagged", - input: "literal `__*important*__` marker", - wantHint: false, - }, - // --- Asterisk-italic wrapping underscore-bold: *__text__* --- - { - name: "asterisk-italic wrapping underscore-bold flagged", - input: "note: *__phrase__* text", - wantHint: true, - }, - { - name: "asterisk-italic wrapping underscore-bold inside fenced code not flagged", - input: "```md\nnote: *__phrase__* sample\n```", - wantHint: false, - }, - // --- Positive tests: real emphasis in prose coexisting with fake in code --- - { - // Underscore-variant in prose must still fire when an asterisk - // variant appears inside a code span — verifies the strip does - // not over-sanitize across the six regex alternatives. - name: "real triple underscores outside inline code still flag when asterisk variant is in code", - input: "real ___strong___ and literal `***shape***` in code", - wantHint: true, - }, - { - // Longer close fence closes properly; real ***emphasis*** after - // the fence must fire. - name: "real emphasis after a fence closed by longer marker still flags", - input: "```\nliteral ***phrase*** in code\n````\n\nand then real ***phrase*** after", - wantHint: true, - }, - { - // 4-space indented "```" is an indented code block, not a fence - // open. The fence helper should refuse it; emphasis outside the - // (non-existent) fence must still be detected. - name: "four-space indented fence-like line does not open a fence for the emphasis check", - input: "prose\n\n ```\n not a fence\n ```\n\nreal ***strong*** here", - wantHint: true, - }, - { - // 3-space indented fence is valid per CommonMark. Emphasis inside - // must be sanitized away, so the check must not fire. - name: "three-space indented fence still hides triple-asterisk inside", - input: " ```\n literal ***text*** inside\n ```", - wantHint: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := checkDocsUpdateBoldItalic(tt.input) - hasHint := got != "" - if hasHint != tt.wantHint { - t.Fatalf("checkDocsUpdateBoldItalic(%q) = %q, wantHint=%v", tt.input, got, tt.wantHint) - } - }) - } -} - -func TestDocsUpdateWarningsAggregates(t *testing.T) { - t.Parallel() - - // Both flags trigger: replace_range with blank line AND triple-asterisk. - warnings := docsUpdateWarnings("replace_range", "***opening***\n\nsecond paragraph") - if len(warnings) != 2 { - t.Fatalf("expected 2 warnings, got %d: %v", len(warnings), warnings) - } -} - -func TestDocsUpdateWarningsEmpty(t *testing.T) { - t.Parallel() - - // Clean markdown in a non-replace mode produces zero warnings. - warnings := docsUpdateWarnings("insert_before", "plain paragraph text") - if len(warnings) != 0 { - t.Fatalf("expected no warnings, got: %v", warnings) - } -} diff --git a/shortcuts/doc/docs_update_test.go b/shortcuts/doc/docs_update_test.go index 6ae06d27..dd856188 100644 --- a/shortcuts/doc/docs_update_test.go +++ b/shortcuts/doc/docs_update_test.go @@ -3,9 +3,12 @@ package doc import ( - "reflect" + "context" "strings" "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" ) // ── V2 tests ── @@ -31,199 +34,102 @@ func TestValidCommandsV2(t *testing.T) { } } -// ── V1 tests ── +func TestDocsUpdateDryRunAcceptsDeprecatedAPIVersionValues(t *testing.T) { + for _, apiVersion := range []string{"v1", "v2"} { + t.Run(apiVersion, func(t *testing.T) { + t.Parallel() -func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) { - t.Parallel() + runtime := newUpdateShortcutTestRuntime(t, apiVersion, nil) + if err := validateUpdateV2(context.Background(), runtime); err != nil { + t.Fatalf("validateUpdateV2() error = %v", err) + } - msg := selectionRequiredMessageV1("replace_all") - for _, needle := range []string{ - "--replace_all mode requires --selection-with-ellipsis or --selection-by-title", - "replace the entire document body", - "--mode overwrite", - } { - if !strings.Contains(msg, needle) { - t.Fatalf("message missing %q: %s", needle, msg) - } + dry := decodeDocDryRun(t, DocsUpdate.DryRun(context.Background(), runtime)) + if len(dry.API) != 1 { + t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API)) + } + if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnUpdateDryRun"; got != want { + t.Fatalf("dry-run URL = %q, want %q", got, want) + } + if got, want := dry.API[0].Body["command"], "block_insert_after"; got != want { + t.Fatalf("dry-run command = %#v, want %q", got, want) + } + if got, want := dry.API[0].Body["block_id"], "-1"; got != want { + t.Fatalf("dry-run block_id = %#v, want %q", got, want) + } + }) } } -func TestSelectionRequiredMessageV1OtherModesDoNotSuggestOverwrite(t *testing.T) { - t.Parallel() - - msg := selectionRequiredMessageV1("replace_range") - if strings.Contains(msg, "--mode overwrite") { - t.Fatalf("replace_range message should not suggest overwrite: %s", msg) - } - if !strings.Contains(msg, "--replace_range mode requires --selection-with-ellipsis or --selection-by-title") { - t.Fatalf("unexpected message: %s", msg) - } -} - -func TestIsWhiteboardCreateMarkdown(t *testing.T) { - t.Run("blank whiteboard tags", func(t *testing.T) { - markdown := "\n" - if !isWhiteboardCreateMarkdown(markdown) { - t.Fatalf("expected blank whiteboard markdown to be treated as whiteboard creation") - } - }) - - t.Run("mermaid code block", func(t *testing.T) { - markdown := "```mermaid\ngraph TD\nA-->B\n```" - if !isWhiteboardCreateMarkdown(markdown) { - t.Fatalf("expected mermaid markdown to be treated as whiteboard creation") - } - }) - - t.Run("plain markdown", func(t *testing.T) { - markdown := "## plain text" - if isWhiteboardCreateMarkdown(markdown) { - t.Fatalf("did not expect plain markdown to be treated as whiteboard creation") - } - }) -} - -func TestCheckOverwriteResourceBlocks(t *testing.T) { - t.Parallel() - +func TestDocsUpdateRejectsLegacyFlags(t *testing.T) { tests := []struct { name string - markdown string - wantWarn bool - wantSubs []string + setFlags map[string]string + want []string }{ { - name: "empty markdown is clean", - markdown: "", - wantWarn: false, - }, - { - name: "plain prose is clean", - markdown: "## Heading\n\nsome text", - wantWarn: false, - }, - { - name: "single whiteboard triggers warning", - markdown: ``, - wantWarn: true, - wantSubs: []string{"1 whiteboard block", "overwrite"}, - }, - { - name: "multiple whiteboards counted", - markdown: "\n", - wantWarn: true, - wantSubs: []string{"2 whiteboard blocks"}, - }, - { - name: "single file attachment triggers warning", - markdown: ``, - wantWarn: true, - wantSubs: []string{"1 file attachment block"}, - }, - { - name: "multiple file attachments counted", - markdown: "\n\n", - wantWarn: true, - wantSubs: []string{"3 file attachment blocks"}, - }, - { - name: "whiteboard and file together both counted", - markdown: "\n", - wantWarn: true, - wantSubs: []string{"1 whiteboard block", "1 file attachment block"}, + name: "legacy mode", + setFlags: map[string]string{"mode": "overwrite"}, + want: []string{ + "docs +update is v2-only", + "the old v1 interface has been shut down", + "legacy v1 flag(s) --mode are no longer supported", + "--mode -> use --command", + "lark-cli skills read lark-doc references/lark-doc-update.md", + "lark-cli skills read lark-doc references/lark-doc-xml.md", + "lark-cli skills read lark-doc references/lark-doc-md.md", + "follow the latest format rules", + "MUST NOT grep/open local SKILL.md files", + "lark-cli docs +update --help", + }, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := checkOverwriteResourceBlocks(tt.markdown) - if (got != "") != tt.wantWarn { - t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn) + + runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags) + err := validateUpdateV2(context.Background(), runtime) + if err == nil { + t.Fatal("expected v2-only validation error") } - for _, sub := range tt.wantSubs { - if !strings.Contains(got, sub) { - t.Errorf("expected warning to contain %q, got: %s", sub, got) + for _, want := range tt.want { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error missing %q: %v", want, err) } } }) } } -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, +func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "+update"} + cmd.Flags().String("api-version", "", "") + cmd.Flags().String("doc", "doxcnUpdateDryRun", "") + cmd.Flags().String("doc-format", "xml", "") + cmd.Flags().String("command", "append", "") + cmd.Flags().Int("revision-id", -1, "") + cmd.Flags().String("content", "

hello

", "") + cmd.Flags().String("pattern", "", "") + cmd.Flags().String("block-id", "", "") + cmd.Flags().String("src-block-ids", "", "") + cmd.Flags().String("mode", "", "") + cmd.Flags().String("markdown", "", "") + cmd.Flags().String("selection-with-ellipsis", "", "") + cmd.Flags().String("selection-by-title", "", "") + cmd.Flags().String("new-title", "", "") + if apiVersion != "" { + if err := cmd.Flags().Set("api-version", apiVersion); err != nil { + t.Fatalf("set api-version: %v", err) } - - normalizeWhiteboardResult(result, "") - - got, ok := result["board_tokens"].([]string) - if !ok { - t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"]) - } - if len(got) != 0 { - t.Fatalf("expected empty board_tokens, got %#v", got) - } - }) - - t.Run("normalizes board_tokens to string slice", func(t *testing.T) { - result := map[string]interface{}{ - "board_tokens": []interface{}{"board_1", "board_2"}, - } - - normalizeWhiteboardResult(result, "") - - want := []string{"board_1", "board_2"} - got, ok := result["board_tokens"].([]string) - if !ok { - t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"]) - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("board_tokens mismatch: got %#v want %#v", got, want) - } - }) - - t.Run("leaves non whiteboard response unchanged", func(t *testing.T) { - result := map[string]interface{}{ - "success": true, - } - - normalizeWhiteboardResult(result, "## plain text") - - if _, ok := result["board_tokens"]; ok { - t.Fatalf("did not expect board_tokens for non-whiteboard markdown") - } - }) -} - -func TestValidateSelectionByTitleV1(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - title string - wantErr bool - errSub string - }{ - {name: "empty title is valid", title: "", wantErr: false}, - {name: "single heading is valid", title: "## Section", wantErr: false}, - {name: "h1 heading is valid", title: "# Top", wantErr: false}, - {name: "deep heading is valid", title: "### Sub-section", wantErr: false}, - {name: "missing hash prefix is invalid", title: "No hash", wantErr: true, errSub: "'#'"}, - {name: "multiline title is invalid", title: "## First\n## Second", wantErr: true, errSub: "single"}, - {name: "title with embedded carriage return is invalid", title: "## Title\r## Next", wantErr: true, errSub: "single"}, - {name: "leading-space heading is valid after trim", title: " ## Section", wantErr: false}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - err := validateSelectionByTitleV1(tt.title) - if (err != nil) != tt.wantErr { - t.Fatalf("validateSelectionByTitleV1(%q) error = %v, wantErr = %v", tt.title, err, tt.wantErr) - } - if tt.wantErr && tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) { - t.Errorf("expected error to contain %q, got: %v", tt.errSub, err) - } - }) + for name, value := range setFlags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } } + return common.TestNewRuntimeContext(cmd, nil) } diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index 8501be01..bd18e949 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -24,13 +24,13 @@ var validCommandsV2 = map[string]bool{ // v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path. func v2UpdateFlags() []common.Flag { return []common.Flag{ - {Name: "command", Desc: "operation: str_replace | 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", 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"}, + {Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()}, + {Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}}, + {Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, + {Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"}, + {Name: "block-id", Desc: "target anchor/block id for block operations; -1 means document end where supported"}, + {Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"}, + {Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"}, } } @@ -39,6 +39,9 @@ func validCommandsV2Keys() []string { } func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { + if err := validateDocsV2Only(runtime, "+update", docsUpdateLegacyFlags()); err != nil { + return err + } if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { return common.FlagErrorf("invalid --doc: %v", err) } diff --git a/shortcuts/doc/markdown_fix.go b/shortcuts/doc/markdown_fix.go deleted file mode 100644 index 0f0f0d68..00000000 --- a/shortcuts/doc/markdown_fix.go +++ /dev/null @@ -1,649 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package doc - -import ( - "fmt" - "io" - "regexp" - "strings" - "unicode" - "unicode/utf8" -) - -// fixExportedMarkdown applies post-processing to Lark-exported Markdown to -// improve round-trip fidelity on re-import: -// -// 1. fixBoldSpacing: removes trailing whitespace before closing ** / *, -// and strips redundant ** from ATX headings. Applied only outside fenced -// code blocks, and skips inline code spans. -// -// 2. normalizeNestedListIndentation: rewrites space-pair-indented nested list -// markers to tab-indented markers. This avoids nested ordered list items -// being flattened or interpreted as plain text/code on re-import. -// -// 3. fixSetextAmbiguity: inserts a blank line before any "---" that immediately -// follows a non-empty line, preventing it from being parsed as a Setext H2. -// Applied only outside fenced code blocks. -// -// 4. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between -// consecutive blockquote content lines so create-doc preserves line breaks. -// Applied only outside fenced code blocks. -// -// 5. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty -// lines at the top level and inside content containers (callout, -// quote-container, lark-td). Code fences are left untouched, and -// consecutive list items / continuations are not separated. -// -// 6. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with -// actual Unicode emoji characters that create-doc understands. Applied only -// outside fenced code blocks. -func fixExportedMarkdown(md string) string { - md = applyOutsideCodeFences(md, fixBoldSpacing) - md = applyOutsideCodeFences(md, normalizeNestedListIndentation) - md = applyOutsideCodeFences(md, fixSetextAmbiguity) - md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks) - md = fixTopLevelSoftbreaks(md) - md = applyOutsideCodeFences(md, fixCalloutEmoji) - // Collapse runs of 3+ consecutive newlines into exactly 2 (one blank line), - // but only outside fenced code blocks to preserve intentional blank lines in code. - md = applyOutsideCodeFences(md, func(s string) string { - for strings.Contains(s, "\n\n\n") { - s = strings.ReplaceAll(s, "\n\n\n", "\n\n") - } - return s - }) - md = strings.TrimRight(md, "\n") + "\n" - return md -} - -// applyOutsideCodeFences applies fn only to content outside fenced code blocks. -// Lines inside fenced code blocks (``` ... ```) are passed through unchanged, -// preventing transforms from corrupting literal code content. -func applyOutsideCodeFences(md string, fn func(string) string) string { - lines := strings.Split(md, "\n") - var out []string - var chunk []string - inCode := false - - flush := func() { - if len(chunk) == 0 { - return - } - out = append(out, strings.Split(fn(strings.Join(chunk, "\n")), "\n")...) - chunk = chunk[:0] - } - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "```") { - if !inCode { - flush() - inCode = true - } else if trimmed == "```" { - inCode = false - } - out = append(out, line) - continue - } - if inCode { - out = append(out, line) - } else { - chunk = append(chunk, line) - } - } - flush() - return strings.Join(out, "\n") -} - -// fixBlockquoteHardBreaks inserts a blank blockquote line (">") between -// consecutive blockquote content lines. This forces each line into its own -// paragraph within the blockquote, so MCP create-doc preserves line breaks -// instead of collapsing them into a single paragraph. -// -// Before: "> line1\n> line2" → After: "> line1\n>\n> line2" -func fixBlockquoteHardBreaks(md string) string { - lines := strings.Split(md, "\n") - out := make([]string, 0, len(lines)*2) - for i, line := range lines { - out = append(out, line) - if strings.HasPrefix(line, "> ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "> ") { - out = append(out, ">") - } - } - return strings.Join(out, "\n") -} - -// fixBoldSpacing normalizes emphasis markers exported by Lark while preserving -// inline code spans: -// -// 1. Removes leading whitespace after opening ** and * delimiters: -// "** text**" → "**text**", "* text*" → "*text*" -// -// 2. Removes trailing whitespace before closing ** and * delimiters: -// "**text **" → "**text**", "*text *" → "*text*" -// -// 3. Removes redundant bold around an entire ATX heading: -// "# **text**" → "# text" -// -// The bold and italic spacing fixes only run on non-code segments so literal -// code content is left unchanged. -var ( - // headingBoldRe uses [^*]+ (no asterisks) to avoid mismatching headings - // that contain multiple disjoint bold spans such as "# **foo** and **bar**". - headingBoldRe = regexp.MustCompile(`(?m)^(#{1,6})\s+\*\*([^*]+)\*\*\s*$`) -) - -func fixBoldSpacing(md string) string { - lines := strings.Split(md, "\n") - for i, line := range lines { - lines[i] = fixBoldSpacingLine(line) - } - md = strings.Join(lines, "\n") - md = headingBoldRe.ReplaceAllString(md, "$1 $2") - return md -} - -// atxHeadingRe matches ATX heading lines (# ... through ###### ...). -var atxHeadingRe = regexp.MustCompile(`^#{1,6}\s`) - -// scanInlineCodeSpans returns the byte ranges [start, end) of all inline code -// spans in line. It handles multi-backtick delimiters (e.g. “ `foo` “) by -// finding the opening run of N backticks and searching for the next identical -// run to close the span, per CommonMark spec §6.1. -func scanInlineCodeSpans(line string) [][2]int { - var spans [][2]int - i := 0 - for i < len(line) { - if line[i] != '`' { - i++ - continue - } - // Count the opening backtick run. - start := i - for i < len(line) && line[i] == '`' { - i++ - } - delim := line[start:i] // e.g. "`" or "``" or "```" - // Search for the closing run of the same length. - j := i - for j <= len(line)-len(delim) { - if line[j] == '`' { - k := j - for k < len(line) && line[k] == '`' { - k++ - } - if k-j == len(delim) { - spans = append(spans, [2]int{start, k}) - i = k - break - } - j = k // skip this backtick run and keep searching - } else { - j++ - } - } - // No closing delimiter found — not a code span, continue. - } - return spans -} - -// fixBoldSpacingLine applies bold/italic trailing-space fixes to a single line, -// skipping content inside inline code spans to avoid corrupting literal code. -// ATX heading lines are also skipped here because headingBoldRe in fixBoldSpacing -// handles them separately, keeping heading-only normalization isolated from the -// inline emphasis spacing scanner below. -func fixBoldSpacingLine(line string) string { - if atxHeadingRe.MatchString(line) { - return line - } - spans := scanInlineCodeSpans(line) - if len(spans) == 0 { - return fixEmphasisSpacingSegment(line) - } - var sb strings.Builder - pos := 0 - for _, loc := range spans { - // Process the non-code segment before this inline code span. - seg := line[pos:loc[0]] - sb.WriteString(fixEmphasisSpacingSegment(seg)) - // Preserve inline code span as-is. - sb.WriteString(line[loc[0]:loc[1]]) - pos = loc[1] - } - // Remaining non-code segment after the last code span. - sb.WriteString(fixEmphasisSpacingSegment(line[pos:])) - return sb.String() -} - -// fixEmphasisSpacingSegment trims only the whitespace immediately inside simple -// *...* and **...** spans. It deliberately ignores runs of 3+ asterisks and -// any candidate whose payload contains another asterisk so nested emphasis-like -// text remains untouched. When both inner sides contain whitespace, single-rune -// payloads are preserved as literal text (for example "* x *" and "** x **"). -func fixEmphasisSpacingSegment(seg string) string { - if !strings.Contains(seg, "*") { - return seg - } - - var sb strings.Builder - pos := 0 - for pos < len(seg) { - openStart, openEnd, ok := nextAsteriskRun(seg, pos) - if !ok { - sb.WriteString(seg[pos:]) - break - } - - sb.WriteString(seg[pos:openStart]) - - markerLen := openEnd - openStart - if markerLen != 1 && markerLen != 2 { - sb.WriteString(seg[openStart:openEnd]) - pos = openEnd - continue - } - - closeStart, closeEnd, ok := nextAsteriskRun(seg, openEnd) - if !ok || closeEnd-closeStart != markerLen { - sb.WriteString(seg[openStart:openEnd]) - pos = openEnd - continue - } - - payload := seg[openEnd:closeStart] - normalized, shouldNormalize := normalizeEmphasisPayload(payload) - if !shouldNormalize { - sb.WriteString(seg[openStart:closeEnd]) - pos = closeEnd - continue - } - - marker := seg[openStart:openEnd] - sb.WriteString(marker) - sb.WriteString(normalized) - sb.WriteString(marker) - pos = closeEnd - } - return sb.String() -} - -func nextAsteriskRun(s string, start int) (runStart, runEnd int, ok bool) { - for i := start; i < len(s); i++ { - if s[i] != '*' { - continue - } - j := i - for j < len(s) && s[j] == '*' { - j++ - } - return i, j, true - } - return 0, 0, false -} - -func normalizeEmphasisPayload(payload string) (string, bool) { - trimmedLeft := strings.TrimLeftFunc(payload, unicode.IsSpace) - trimmed := strings.TrimRightFunc(trimmedLeft, unicode.IsSpace) - if trimmed == "" { - return payload, false - } - - hasLeadingSpace := len(trimmedLeft) != len(payload) - hasTrailingSpace := len(trimmed) != len(trimmedLeft) - if !hasLeadingSpace && !hasTrailingSpace { - return payload, true - } - - if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 { - return payload, false - } - return trimmed, true -} - -var setextRe = regexp.MustCompile(`(?m)^([^\n]+)\n(-{3,}\s*$)`) - -func fixSetextAmbiguity(md string) string { - return setextRe.ReplaceAllString(md, "$1\n\n$2") -} - -// calloutTypeColors maps the semantic type= shorthand to a recommended -// [background-color, border-color] pair for Feishu callout blocks. -// Used only for hint messages — the Markdown itself is never rewritten. -var calloutTypeColors = map[string][2]string{ - "warning": {"light-yellow", "yellow"}, - "caution": {"light-orange", "orange"}, - "note": {"light-blue", "blue"}, - "info": {"light-blue", "blue"}, - "tip": {"light-green", "green"}, - "success": {"light-green", "green"}, - "check": {"light-green", "green"}, - "error": {"light-red", "red"}, - "danger": {"light-red", "red"}, - "important": {"light-purple", "purple"}, -} - -// calloutOpenTagRe matches a opening tag. -var calloutOpenTagRe = regexp.MustCompile(`]*)?>`) - -// calloutTypeAttrRe extracts the value of a type= attribute (single or -// double quoted) from a callout opening tag's attribute string. The -// (?:^|\s) anchor instead of \b is intentional: \b sits at any -// word/non-word boundary, and `-` is a non-word character, so -// `\btype=` would also match the suffix of `data-type=` and yield a -// bogus type lookup. Anchoring on start-of-string-or-whitespace -// requires a real attribute separator before the name. -var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`) - -// calloutBackgroundColorAttrRe matches a background-color= attribute -// name with optional whitespace around the equals sign, so forms like -// `background-color="..."` and `background-color = "..."` are both -// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same -// reason: `data-background-color="..."` must not look like a present -// background-color and silently suppress the hint. -var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`) - -// WarnCalloutType scans md for callout tags that carry a type= attribute but -// no background-color= attribute, then writes a hint line to w for each one -// suggesting the explicit Feishu color attributes to use instead. -// -// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they -// are documentation samples, not real callouts the user wants Feishu to -// render. Fence detection uses the shared codeFenceOpenMarker / -// isCodeFenceClose helpers so both backtick and tilde fences are handled -// (matching CommonMark §4.5). -// -// The Markdown is not modified — the caller is responsible for acting on -// the hints or ignoring them. This keeps the create/update path -// transparent: user input reaches create-doc exactly as written. -func WarnCalloutType(md string, w io.Writer) { - fenceMarker := "" - for _, line := range strings.Split(md, "\n") { - if fenceMarker != "" { - // Inside a fenced block — skip everything until the matching - // closer. Code samples that show literal - // must not produce a phantom hint. - if isCodeFenceClose(line, fenceMarker) { - fenceMarker = "" - } - continue - } - if marker := codeFenceOpenMarker(line); marker != "" { - fenceMarker = marker - continue - } - scanCalloutTagsForWarning(line, w) - } -} - -// scanCalloutTagsForWarning emits a hint to w for every -// tag in s that lacks an explicit background-color= attribute. Pulled out -// of WarnCalloutType so the line walker only handles fence state and the -// per-tag scan is its own readable unit. -// -// The previous implementation routed the tag iteration through -// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always -// returned the original tag and threw the rebuilt string away — using a -// rewrite primitive purely for its iteration side-effect, plus a second -// regex execution to recover the capture groups inside the callback. -// FindAllStringSubmatch hands us both the iteration and the groups in one -// pass, no allocation thrown away. -func scanCalloutTagsForWarning(s string, w io.Writer) { - for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) { - attrs := m[1] - // Skip tags that already carry an explicit background-color. - if calloutBackgroundColorAttrRe.MatchString(attrs) { - continue - } - parts := calloutTypeAttrRe.FindStringSubmatch(attrs) - if len(parts) < 3 { - continue // no type= attribute - } - // parts[1] is the double-quoted capture, parts[2] is single-quoted. - typeName := parts[1] - if typeName == "" { - typeName = parts[2] - } - colors, ok := calloutTypeColors[typeName] - if !ok { - continue // unknown type — no hint to give - } - fmt.Fprintf(w, - "hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n", - typeName, colors[0], colors[1]) - } -} - -// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual -// Unicode emoji characters that create-doc accepts. -var calloutEmojiAliases = map[string]string{ - "warning": "⚠️", - "note": "📝", - "tip": "💡", - "info": "ℹ️", - "check": "✅", - "success": "✅", - "error": "❌", - "danger": "🚨", - "important": "❗", - "caution": "⚠️", - "question": "❓", - "forbidden": "🚫", - "fire": "🔥", - "star": "⭐", - "pin": "📌", - "clock": "🕐", - "gift": "🎁", - "eyes": "👀", - "bulb": "💡", - "memo": "📝", - "link": "🔗", - "key": "🔑", - "lock": "🔒", - "thumbsup": "👍", - "thumbsdown": "👎", - "rocket": "🚀", - "construction": "🚧", -} - -// calloutEmojiRe matches emoji="" in callout opening tags. -var calloutEmojiRe = regexp.MustCompile(`(]*\bemoji=")([^"]+)(")`) - -// fixCalloutEmoji replaces named emoji aliases in callout tags with actual -// Unicode emoji characters. fetch-doc sometimes emits emoji="warning" instead -// of emoji="⚠️"; create-doc only accepts Unicode emoji. -func fixCalloutEmoji(md string) string { - return calloutEmojiRe.ReplaceAllStringFunc(md, func(match string) string { - parts := calloutEmojiRe.FindStringSubmatch(match) - if len(parts) != 4 { - return match - } - name := parts[2] - if emoji, ok := calloutEmojiAliases[name]; ok { - return parts[1] + emoji + parts[3] - } - return match - }) -} - -// isTableStructuralTag returns true for lark-table tags that are structural -// (table/tr/td open/close) and should not themselves trigger blank-line insertion. -func isTableStructuralTag(s string) bool { - return strings.HasPrefix(s, "", ""}, - {""}, - {"", ""}, -} - -// listItemRe matches unordered and ordered list item markers, including -// indented (nested) items. -var listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)]) `) - -// nestedListIndentRe matches nested list item markers indented with pairs of -// spaces. We rewrite those space pairs to tabs because some downstream -// round-trip paths treat multi-space indented ordered items as flat items or -// literal text, while tab indentation remains nested and avoids 4-space code -// block ambiguity. -var nestedListIndentRe = regexp.MustCompile(`^( {2,})([-*+]|\d+[.)]) `) - -func normalizeNestedListIndentation(md string) string { - lines := strings.Split(md, "\n") - for i, line := range lines { - matches := nestedListIndentRe.FindStringSubmatch(line) - if len(matches) != 3 { - continue - } - if !hasPreviousNonBlankListItem(lines, i) { - continue - } - indent := matches[1] - if len(indent)%2 != 0 { - continue - } - tabs := strings.Repeat("\t", len(indent)/2) - lines[i] = tabs + line[len(indent):] - } - return strings.Join(lines, "\n") -} - -func hasPreviousNonBlankListItem(lines []string, index int) bool { - for i := index - 1; i >= 0; i-- { - trimmed := strings.TrimSpace(lines[i]) - if trimmed == "" { - return false - } - return listItemRe.MatchString(lines[i]) - } - return false -} - -// isListItemOrContinuation returns true for lines that are part of a list: -// either a list item marker line or an indented continuation of a list item. -// This is used to prevent blank lines being inserted between tight list lines, -// which would turn a tight list into a loose list and change rendering. -func isListItemOrContinuation(line string) bool { - if listItemRe.MatchString(line) { - return true - } - // Continuation lines are indented by at least 2 spaces or 1 tab. - return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") -} - -// fixTopLevelSoftbreaks ensures that adjacent non-empty content lines are -// separated by a blank line in the following contexts: -// 1. Top level (depth == 0): every Lark block becomes its own Markdown paragraph. -// 2. Inside content containers (, , ): -// multi-line content is preserved as separate paragraphs. -// -// Structural table tags (, , and their closing -// counterparts) never trigger blank-line insertion themselves. Fenced code -// blocks (``` ... ```) are left completely untouched. Consecutive list items -// and list continuations are not separated (to preserve tight lists). -func fixTopLevelSoftbreaks(md string) string { - lines := strings.Split(md, "\n") - out := make([]string, 0, len(lines)*2) - - inCodeBlock := false - // containerDepth > 0 means we are inside a content container. - containerDepth := 0 - // tableDepth tracks nesting (outer structure, not content). - tableDepth := 0 - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - - // --- Track fenced code blocks — skip all processing inside. --- - // Any ``` line opens a block; only plain ``` (no language id) closes it. - if strings.HasPrefix(trimmed, "```") { - if inCodeBlock { - if trimmed == "```" { - inCodeBlock = false - } - } else { - inCodeBlock = true - } - out = append(out, line) - continue - } - - if !inCodeBlock { - // --- Track content containers. --- - for _, cc := range contentContainers { - if strings.HasPrefix(trimmed, cc[0]) { - containerDepth++ - } - if strings.Contains(trimmed, cc[1]) { - containerDepth-- - if containerDepth < 0 { - containerDepth = 0 - } - } - } - - // --- Track table structure (outer, non-content). --- - if strings.HasPrefix(trimmed, "") { - tableDepth-- - if tableDepth < 0 { - tableDepth = 0 - } - } - } - - // --- Decide whether to insert a blank line before this line. --- - if !inCodeBlock && trimmed != "" && i > 0 { - // Skip structural table tags — they are not content lines. - isStructural := isTableStructuralTag(trimmed) - - // Don't split consecutive blockquote lines ("> ...") — they form - // one continuous blockquote in the original document. - isBlockquote := strings.HasPrefix(trimmed, "> ") || trimmed == ">" - - // Only closing container tags suppress blank-line insertion. - // Opening container tags may still receive a blank line before them - // (e.g. two consecutive blocks need a blank between them). - isContainerTag := false - for _, cc := range contentContainers { - closingTag := " 0, not in outer table) - // AND this line is actual content (not structural/blockquote/container-tag). - inContent := tableDepth == 0 || containerDepth > 0 - if !isStructural && !isBlockquote && !isContainerTag && inContent { - // Don't split consecutive list items / continuations — inserting a - // blank line between them turns a tight list into a loose list. - isListRelated := isListItemOrContinuation(line) - prevIsListRelated := len(out) > 0 && isListItemOrContinuation(out[len(out)-1]) - if !(isListRelated && prevIsListRelated) { - prev := "" - if len(out) > 0 { - prev = strings.TrimSpace(out[len(out)-1]) - } - if prev != "" && !isTableStructuralTag(prev) { - out = append(out, "") - } - } - } - } - - out = append(out, line) - } - - return strings.Join(out, "\n") -} diff --git a/shortcuts/doc/markdown_fix_hardening_test.go b/shortcuts/doc/markdown_fix_hardening_test.go deleted file mode 100644 index 36264f87..00000000 --- a/shortcuts/doc/markdown_fix_hardening_test.go +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package doc - -import ( - "strings" - "testing" -) - -// TestFixExportedMarkdownIdempotent asserts the core promise of the exported -// markdown pipeline: applying the fixes twice produces the same result as -// applying them once. Round-trip formatting relies on this invariant, so any -// transform that keeps rewriting its own output would break fetch → edit → -// update → fetch stability. -func TestFixExportedMarkdownIdempotent(t *testing.T) { - fixtures := map[string]string{ - "kitchen sink": strings.Join([]string{ - "# **Title**", - "paragraph one", - "paragraph two", - "**bold ** and * italic*", - "", - "> q1", - "> q2", - "", - "1. parent", - " 1. child", - " 1. grandchild", - "", - "", - "callout body line 1", - "callout body line 2", - "", - "", - "some text", - "---", - "", - "```go", - "// code content with markdown-like shapes must survive as-is", - "**foo **", - "* hello*", - " 1. nested", - "> q", - "---", - "```", - "", - }, "\n"), - - "cjk content": strings.Join([]string{ - "# **测试标题**", - "段落一", - "段落二", - "**有用性 ** and * 关键 *", - "", - "1. 父项", - " 1. 子项", - "", - }, "\n"), - - "nested containers": strings.Join([]string{ - "", - "line a", - "line b", - "", - "", - "", - "quoted 1", - "quoted 2", - "", - "", - }, "\n"), - } - - for name, fixture := range fixtures { - t.Run(name, func(t *testing.T) { - once := fixExportedMarkdown(fixture) - twice := fixExportedMarkdown(once) - if once != twice { - t.Errorf("fixExportedMarkdown is not idempotent for %q\nfirst pass:\n%s\nsecond pass:\n%s", - name, once, twice) - } - }) - } -} - -// TestFixExportedMarkdownPreservesFencedCodeByteForByte packs a fenced code -// block with content that every individual transform in the pipeline would -// normally rewrite, and asserts the fence content comes out byte-for-byte -// identical. This is the pipeline's strongest invariant — users' code samples -// must never be silently modified by a formatting pass. -func TestFixExportedMarkdownPreservesFencedCodeByteForByte(t *testing.T) { - // Every line below is something at least one transform would touch if it - // appeared outside a fence. None of it must change. - dangerous := strings.Join([]string{ - "**foo **", // fixBoldSpacing — trailing space bold - "* hello*", // fixBoldSpacing — leading space italic - "# **heading**", // fixBoldSpacing — redundant heading bold - "para1", // fixTopLevelSoftbreaks — adjacent paragraphs - "para2", - "> q1", // fixBlockquoteHardBreaks — blockquote pair - "> q2", - "some text", // fixSetextAmbiguity — text before --- - "---", - " 1. nested", // normalizeNestedListIndentation - ``, // fixCalloutEmoji — emoji alias - }, "\n") - - // Wrap the dangerous content in a triple-backtick fence and surround with - // content so the pipeline has adjacent regions to potentially touch. - input := "before\n\n```\n" + dangerous + "\n```\n\nafter\n" - - got := fixExportedMarkdown(input) - - // Extract the fence content from the output and compare to the input fence - // content byte-for-byte. - gotFence, ok := extractFirstFenceContent(got) - if !ok { - t.Fatalf("fixExportedMarkdown output lost its fenced code block:\n%s", got) - } - if gotFence != dangerous { - t.Errorf("fenced code content was modified\nwant (bytes): %q\ngot (bytes): %q", - dangerous, gotFence) - } -} - -// extractFirstFenceContent returns the inner text of the first triple-backtick -// fenced code block it finds, or ("", false) if none is present. -func extractFirstFenceContent(md string) (string, bool) { - const fence = "```" - open := strings.Index(md, fence) - if open < 0 { - return "", false - } - // Skip the fence marker and its info-string line. - rest := md[open+len(fence):] - lineEnd := strings.Index(rest, "\n") - if lineEnd < 0 { - return "", false - } - rest = rest[lineEnd+1:] - close := strings.Index(rest, "\n"+fence) - if close < 0 { - return "", false - } - return rest[:close], true -} - -// TestFixExportedMarkdownPreservesCRLF feeds CRLF-terminated markdown (Windows -// line endings) through the pipeline and asserts that line endings are -// preserved AND the emphasis/heading transforms still apply — neither -// silently-LF-normalized nor passed through unchanged. -func TestFixExportedMarkdownPreservesCRLF(t *testing.T) { - lf := "# **Title**\nparagraph one\nparagraph two\n**bold **\n" - crlf := strings.ReplaceAll(lf, "\n", "\r\n") - - got := fixExportedMarkdown(crlf) - - // Transforms must still fire: heading bold stripped, trailing-space bold trimmed. - if strings.Contains(got, "**Title**") { - t.Errorf("heading bold not stripped on CRLF input:\n%q", got) - } - if strings.Contains(got, "**bold **") { - t.Errorf("trailing-space bold not fixed on CRLF input:\n%q", got) - } - // CRLF line endings must survive — we don't want to silently normalize a - // Windows author's document to LF. - if !strings.Contains(got, "\r\n") { - t.Errorf("CRLF line endings were normalized away:\n%q", got) - } -} - -// TestFixExportedMarkdownTransformInteractions covers shapes where more than -// one transform fires on the same input. Each transform is individually tested -// elsewhere; these cases guard against composition regressions. -func TestFixExportedMarkdownTransformInteractions(t *testing.T) { - tests := []struct { - name string - input string - wantContains []string // substrings that must be present after fixes - wantAbsent []string // substrings that must be absent after fixes - }{ - { - name: "nested list item with trailing-space bold", - input: "1. parent\n 1. **child **\n", - wantContains: []string{ - "\t1.", // nested indent converted to tab - "**child**", // trailing space trimmed - }, - wantAbsent: []string{ - " 1.", // original two-space indent gone - "**child **", // original trailing space gone - }, - }, - { - name: "paragraph followed by list", - input: "paragraph\n- item a\n- item b\n", - wantContains: []string{ - "paragraph\n\n- item a", // blank line inserted at text-to-list transition - }, - wantAbsent: []string{ - "\n\n\n", // no triple newline - }, - }, - { - name: "callout containing list with emphasis", - input: "\n- **item **\n- another\n\n", - wantContains: []string{ - "**item**", // trailing-space bold fixed inside callout - }, - wantAbsent: []string{ - "**item **", - }, - }, - { - name: "heading followed by paragraph with bold", - input: "# **Title**\nbody **text **\n", - wantContains: []string{ - "# Title", // heading bold stripped - "body **text**", // paragraph bold trimmed, not stripped - }, - wantAbsent: []string{ - "# **Title**", - "body **text **", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := fixExportedMarkdown(tt.input) - for _, want := range tt.wantContains { - if !strings.Contains(got, want) { - t.Errorf("want substring %q not found in output:\n%s", want, got) - } - } - for _, unwanted := range tt.wantAbsent { - if strings.Contains(got, unwanted) { - t.Errorf("unwanted substring %q still present in output:\n%s", unwanted, got) - } - } - }) - } -} - -// TestNormalizeNestedListIndentationDocumentedSkips locks in the deliberate -// "do nothing" branches of normalizeNestedListIndentation. Each case below is -// a shape the function intentionally does not rewrite; if a future change to -// the heuristic flips one of these, we want the regression to be visible in -// the test diff rather than silently changing user documents. -func TestNormalizeNestedListIndentationDocumentedSkips(t *testing.T) { - tests := []struct { - name string - input string - // want is identical to input — we are asserting "no change". - }{ - { - name: "three-space indent (odd) under list item stays unchanged", - input: "1. parent\n 1. child", - }, - { - name: "five-space indent (odd) under list item stays unchanged", - input: "- parent\n - deep", - }, - { - name: "two-space indent without a parent list item stays unchanged", - input: "plain paragraph\n - not nested", - }, - { - name: "blank-line-separated loose-list sibling stays unchanged", - input: "1. a\n\n 1. b", - }, - { - name: "four-space indented code block under list item stays unchanged", - input: "- parent\n\n 1. code sample", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeNestedListIndentation(tt.input) - if got != tt.input { - t.Errorf("normalizeNestedListIndentation unexpectedly rewrote documented-skip input\ninput: %q\ngot: %q", tt.input, got) - } - }) - } -} diff --git a/shortcuts/doc/markdown_fix_test.go b/shortcuts/doc/markdown_fix_test.go deleted file mode 100644 index 1aafbe1b..00000000 --- a/shortcuts/doc/markdown_fix_test.go +++ /dev/null @@ -1,569 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package doc - -import ( - "strings" - "testing" -) - -func TestFixBoldSpacing(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "leading space after opening bold", - input: "** hello**", - want: "**hello**", - }, - { - name: "leading space after opening italic", - input: "* hello*", - want: "*hello*", - }, - { - name: "leading and trailing spaces inside bold are collapsed", - input: "** hello **", - want: "**hello**", - }, - { - name: "leading and trailing spaces inside italic are collapsed", - input: "* hello *", - want: "*hello*", - }, - { - name: "multiple spaced italic spans on one line are each collapsed", - input: "* a* * b*", - want: "*a* *b*", - }, - { - name: "ambiguous italic span stays literal", - input: "2 * x * y", - want: "2 * x * y", - }, - { - name: "ambiguous bold span stays literal", - input: "2 ** x ** y", - want: "2 ** x ** y", - }, - { - name: "single-rune italic with spaces on both sides stays literal", - input: "* x *", - want: "* x *", - }, - { - name: "single-rune bold with spaces on both sides stays literal", - input: "** x **", - want: "** x **", - }, - { - name: "triple-asterisk near miss stays literal", - input: "*** hello**", - want: "*** hello**", - }, - { - name: "trailing space before closing bold", - input: "**hello **", - want: "**hello**", - }, - { - name: "trailing space before closing italic", - input: "*hello *", - want: "*hello*", - }, - { - name: "redundant bold in h1", - input: "# **Title**", - want: "# Title", - }, - { - name: "redundant bold in h2", - input: "## **Section**", - want: "## Section", - }, - { - name: "no change needed for clean bold", - input: "**bold**", - want: "**bold**", - }, - { - name: "multiple lines processed independently", - input: "**foo **\n**bar **", - want: "**foo**\n**bar**", - }, - { - name: "inline code span not modified", - input: "`**hello **`", - want: "`**hello **`", - }, - { - name: "inline code preserved, bold outside fixed", - input: "**foo ** and `**bar **`", - want: "**foo** and `**bar **`", - }, - { - name: "inline code with spaced italic stays literal while outside span is fixed", - input: "`* hello *` and * hello *", - want: "`* hello *` and *hello*", - }, - { - name: "opening space inside text tag fixed", - input: `** Helpful - 有用性:**`, - want: `**Helpful - 有用性:**`, - }, - { - name: "double-backtick inline code not modified", - input: "``**hello **`` and **world **", - want: "``**hello **`` and **world**", - }, - { - name: "double-backtick span containing literal backtick not modified", - input: "`` a`b `` and **bold **", - want: "`` a`b `` and **bold**", - }, - { - name: "heading with multiple bold spans left unchanged", - input: "# **foo** and **bar**", - want: "# **foo** and **bar**", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := fixBoldSpacing(tt.input) - if got != tt.want { - t.Errorf("fixBoldSpacing(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestFixSetextAmbiguity(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "paragraph followed by ---", - input: "some text\n---", - want: "some text\n\n---", - }, - { - name: "blank line before --- already", - input: "some text\n\n---", - want: "some text\n\n---", - }, - { - name: "heading not affected", - input: "# Heading\n---", - want: "# Heading\n\n---", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := fixSetextAmbiguity(tt.input) - if got != tt.want { - t.Errorf("fixSetextAmbiguity(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestFixBlockquoteHardBreaks(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "two consecutive blockquote lines", - input: "> line1\n> line2", - want: "> line1\n>\n> line2", - }, - { - name: "three consecutive blockquote lines", - input: "> a\n> b\n> c", - want: "> a\n>\n> b\n>\n> c", - }, - { - name: "single blockquote line unchanged", - input: "> only one", - want: "> only one", - }, - { - name: "non-blockquote not affected", - input: "line1\nline2", - want: "line1\nline2", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := fixBlockquoteHardBreaks(tt.input) - if got != tt.want { - t.Errorf("fixBlockquoteHardBreaks(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestFixTopLevelSoftbreaks(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "adjacent top-level lines get blank line", - input: "paragraph one\nparagraph two", - want: "paragraph one\n\nparagraph two", - }, - { - name: "lines inside code block not modified", - input: "```\nline1\nline2\n```", - want: "```\nline1\nline2\n```", - }, - { - // callout is a content container: blank lines are inserted between inner lines. - name: "lines inside callout get blank line between them", - input: "\nline1\nline2\n", - want: "\n\nline1\n\nline2\n", - }, - { - name: "lark-td cell content gets blank line", - input: "\nline1\nline2\n", - want: "\nline1\n\nline2\n", - }, - { - name: "structural lark-table tags not separated", - input: "\n\n\ncontent\n\n\n", - want: "\n\n\ncontent\n\n\n", - }, - { - name: "blockquote lines not split", - input: "> line1\n> line2", - want: "> line1\n> line2", - }, - { - name: "consecutive unordered list items not split", - input: "- item a\n- item b\n- item c", - want: "- item a\n- item b\n- item c", - }, - { - name: "consecutive ordered list items not split", - input: "1. first\n2. second\n3. third", - want: "1. first\n2. second\n3. third", - }, - { - name: "list continuation not split from item", - input: "- item a\n continuation", - want: "- item a\n continuation", - }, - { - name: "text to list transition gets blank line", - input: "paragraph\n- list item", - want: "paragraph\n\n- list item", - }, - { - name: "adjacent callout blocks get blank line between them", - input: "\ncontent1\n\n\ncontent2\n", - want: "\n\ncontent1\n\n\n\n\ncontent2\n", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := fixTopLevelSoftbreaks(tt.input) - if got != tt.want { - t.Errorf("fixTopLevelSoftbreaks(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestNormalizeNestedListIndentation(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "nested ordered list uses tabs instead of space pairs", - input: "1. parent\n 1. child\n 1. grandchild", - want: "1. parent\n\t1. child\n\t\t1. grandchild", - }, - { - name: "nested mixed list markers use tabs instead of space pairs", - input: "- parent\n - child\n 1. grandchild", - want: "- parent\n\t- child\n\t\t1. grandchild", - }, - { - name: "top-level list unchanged", - input: "1. parent\n2. sibling", - want: "1. parent\n2. sibling", - }, - { - name: "indented top-level marker without parent list stays unchanged", - input: "paragraph\n\n 1. item", - want: "paragraph\n\n 1. item", - }, - { - name: "blank-line-separated loose-list sibling stays unchanged", - input: "1. a\n\n 1. b", - want: "1. a\n\n 1. b", - }, - { - name: "indented code block inside list item stays unchanged", - input: "- parent\n\n 1. code", - want: "- parent\n\n 1. code", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeNestedListIndentation(tt.input) - if got != tt.want { - t.Errorf("normalizeNestedListIndentation(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestFixExportedMarkdown(t *testing.T) { - // End-to-end: all fixes applied together - input := "# **Title**\nparagraph one\nparagraph two\n**bold **\n> q1\n> q2\nsome text\n---" - result := fixExportedMarkdown(input) - - if strings.Contains(result, "# **Title**") { - t.Error("expected heading bold to be stripped") - } - if !strings.Contains(result, "paragraph one\n\nparagraph two") { - t.Error("expected blank line between top-level paragraphs") - } - if strings.Contains(result, "**bold **") { - t.Error("expected trailing space in bold to be fixed") - } - if !strings.Contains(result, ">\n> q2") { - t.Error("expected blockquote hard break inserted") - } - if strings.Contains(result, "some text\n---") { - t.Error("expected blank line before --- to prevent setext heading") - } - // Should end with exactly one newline - if !strings.HasSuffix(result, "\n") || strings.HasSuffix(result, "\n\n") { - t.Errorf("expected result to end with exactly one newline, got %q", result[len(result)-5:]) - } - // No triple newlines - if strings.Contains(result, "\n\n\n") { - t.Error("expected no triple newlines in output") - } -} - -func TestWarnCalloutType(t *testing.T) { - tests := []struct { - name string - input string - wantHint bool // whether a hint line is expected - hintContains string // substring the hint must contain - }{ - { - name: "warning type without background-color emits hint", - input: ``, - wantHint: true, - hintContains: `background-color="light-yellow"`, - }, - { - name: "info type without background-color emits hint", - input: ``, - wantHint: true, - hintContains: `background-color="light-blue"`, - }, - { - name: "single-quoted type attribute emits hint", - input: ``, - wantHint: true, - hintContains: `background-color="light-yellow"`, - }, - { - name: "explicit background-color suppresses hint", - input: ``, - wantHint: false, - }, - { - name: "whitespace around equals is tolerated in background-color", - input: ``, - wantHint: false, - }, - { - name: "unknown type emits no hint", - input: ``, - wantHint: false, - }, - { - name: "no type attribute emits no hint", - input: ``, - wantHint: false, - }, - { - name: "non-callout tag emits no hint", - input: `
`, - wantHint: false, - }, - { - name: "hint includes border-color suggestion", - input: ``, - wantHint: true, - hintContains: `border-color="red"`, - }, - { - // Regression: the old `\btype=` regex matched the suffix of - // `data-type=` because `-` is a non-word character, so a tag - // carrying only data-attrs would silently get a bogus hint. - // The (?:^|\s) anchor requires a real attribute separator. - name: "data-type attribute does not trigger hint", - input: ``, - wantHint: false, - }, - { - // Symmetric guard for the background-color regex: a future - // `data-background-color=` attribute must not be mistaken - // for a present background-color and silently suppress the - // hint that the real type= would otherwise produce. - name: "data-background-color does not suppress hint", - input: ``, - wantHint: true, - hintContains: `background-color="light-yellow"`, - }, - { - // Regression for the code-fence skip: a documentation sample - // inside a ``` fence is NOT a real callout the user wants - // rendered, so it must produce no stderr noise. - name: "callout inside backtick fence emits no hint", - input: "```markdown\n" + - `` + "\n" + - "```\n", - wantHint: false, - }, - { - // Same skip works for tilde fences (CommonMark §4.5 makes - // `~~~` an equivalent fence character). - name: "callout inside tilde fence emits no hint", - input: "~~~markdown\n" + - `` + "\n" + - "~~~\n", - wantHint: false, - }, - { - // Closing the fence must restore normal scanning: a real - // callout that follows a documentation block still gets a - // hint. Pins that fenceMarker is reset, not stuck. - name: "callout after fence close still emits hint", - input: "```markdown\n" + - `sample` + "\n" + - "```\n" + - `real` + "\n", - wantHint: true, - hintContains: `border-color="red"`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf strings.Builder - WarnCalloutType(tt.input, &buf) - got := buf.String() - if tt.wantHint { - if got == "" { - t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input) - return - } - if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) { - t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains) - } - } else { - if got != "" { - t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got) - } - } - }) - } -} - -func TestFixCalloutEmoji(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "warning alias replaced", - input: ``, - want: ``, - }, - { - name: "tip alias replaced", - input: ``, - want: ``, - }, - { - name: "actual emoji unchanged", - input: ``, - want: ``, - }, - { - name: "unknown alias unchanged", - input: ``, - want: ``, - }, - { - name: "non-callout tag unchanged", - input: `
`, - want: `
`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := fixCalloutEmoji(tt.input) - if got != tt.want { - t.Errorf("fixCalloutEmoji(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestApplyOutsideCodeFences(t *testing.T) { - // Transforms should not modify content inside fenced code blocks. - input := "```md\n**x **\n> a\n> b\nline\n---\n```" - - if got := applyOutsideCodeFences(input, fixBoldSpacing); got != input { - t.Fatalf("fixBoldSpacing (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input) - } - if got := applyOutsideCodeFences(input, fixSetextAmbiguity); got != input { - t.Fatalf("fixSetextAmbiguity (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input) - } - if got := applyOutsideCodeFences(input, fixBlockquoteHardBreaks); got != input { - t.Fatalf("fixBlockquoteHardBreaks (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input) - } - - // Content outside the fence should still be transformed. - mixed := "**foo ** before\n```\n**x **\n```\n**bar ** after" - got := applyOutsideCodeFences(mixed, fixBoldSpacing) - if strings.Contains(got, "**foo **") { - t.Errorf("fixBoldSpacing did not fix bold before fence: %q", got) - } - if strings.Contains(got, "**bar **") { - t.Errorf("fixBoldSpacing did not fix bold after fence: %q", got) - } - if !strings.Contains(got, "```\n**x **\n```") { - t.Errorf("fixBoldSpacing modified content inside fence: %q", got) - } -} - -func TestFixTopLevelSoftbreaksQuoteContainer(t *testing.T) { - input := "\nline1\nline2\n" - got := fixTopLevelSoftbreaks(input) - // quote-container is a content container: blank lines inserted between inner lines. - want := "\n\nline1\n\nline2\n" - if got != want { - t.Errorf("fixTopLevelSoftbreaks quote-container = %q, want %q", got, want) - } -} diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go index 3a6f84b0..515aaeb4 100644 --- a/shortcuts/doc/shortcuts.go +++ b/shortcuts/doc/shortcuts.go @@ -9,28 +9,44 @@ import ( "github.com/spf13/cobra" - "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/shortcuts/common" ) const docsServiceHelpDefault = `Document and content operations.` -const docsServiceHelpV2 = `Document and content operations (v2).` +const docsSkillReadCommand = "lark-cli skills read lark-doc" +const docsXMLSkillReadCommand = "lark-cli skills read lark-doc references/lark-doc-xml.md" +const docsMDSkillReadCommand = "lark-cli skills read lark-doc references/lark-doc-md.md" +const docsContentSkillHelp = "AI agents MUST read " + + docsXMLSkillReadCommand + " before writing any --content payload; " + + "when using --doc-format markdown, also read " + docsMDSkillReadCommand + ". " + + "Follow the latest rules there, and MUST NOT grep/open local SKILL.md files " + + "to discover this guidance" -var docsVersionSelectionTips = []string{ - "Docs v1 is deprecated and will be removed soon. Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.", - "After confirming lark-doc is v2, follow that skill's examples and use `--api-version v2` with docs +create, docs +fetch, and docs +update.", -} - -var docsV2VersionSelectionTips = []string{ - "Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.", -} - -func docsTipsForVersion(apiVersion string) []string { - if apiVersion == "v2" { - return docsV2VersionSelectionTips +func docsSkillReadCommandForShortcut(shortcut string) string { + switch strings.TrimPrefix(shortcut, "+") { + case "create": + return docsSkillReadCommand + " references/lark-doc-create.md" + case "fetch": + return docsSkillReadCommand + " references/lark-doc-fetch.md" + case "update": + return docsSkillReadCommand + " references/lark-doc-update.md" + default: + return docsSkillReadCommand + } +} + +func docsHelpCommandForShortcut(shortcut string) string { + switch strings.TrimPrefix(shortcut, "+") { + case "create": + return "lark-cli docs +create --help" + case "fetch": + return "lark-cli docs +fetch --help" + case "update": + return "lark-cli docs +update --help" + default: + return "lark-cli docs --help" } - return docsVersionSelectionTips } // Shortcuts returns all docs shortcuts. @@ -48,45 +64,32 @@ func Shortcuts() []common.Shortcut { } // ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command. -// The shortcut-level help remains compatible with legacy v1 skills; this parent -// help switches docs guidance to match the selected API version. func ConfigureServiceHelp(cmd *cobra.Command) { if cmd == nil { return } - serviceCmd := cmd - cmd.Long = strings.TrimSpace(docsServiceHelpDefault) - if cmd.Flags().Lookup("api-version") == nil { - cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)") - cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp - }) - } - - defaultHelp := cmd.HelpFunc() - cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - if cmd != serviceCmd { - defaultHelp(cmd, args) - return - } - - apiVersion, _ := cmd.Flags().GetString("api-version") - previousLong := cmd.Long - if apiVersion == "v2" { - cmd.Long = strings.TrimSpace(docsServiceHelpV2) - } else { - cmd.Long = strings.TrimSpace(docsServiceHelpDefault) - } - defer func() { - cmd.Long = previousLong - }() - - defaultHelp(cmd, args) - out := cmd.OutOrStdout() - fmt.Fprintln(out) - fmt.Fprintln(out, "Tips:") - for _, tip := range docsTipsForVersion(apiVersion) { - fmt.Fprintf(out, " • %s\n", tip) - } - }) + cmd.Long = docsHelpLong(docsServiceHelpDefault, docsSkillReadCommand) +} + +func installDocsShortcutHelp(command string) func(*cobra.Command) { + return func(cmd *cobra.Command) { + cmd.Long = docsHelpLong(cmd.Short, docsSkillReadCommandForShortcut(command)) + } +} + +func docsHelpLong(summary, skillReadCommand string) string { + return strings.TrimSpace(fmt.Sprintf(`%s + +Start here (required for AI agents): + %s + + AI agents MUST read the matching embedded skill before choosing flags + or running docs commands. Do not skip this step, and do not infer + workflows from --help alone. MUST NOT grep/open local SKILL.md files + to discover this guidance; use %s so content stays version-matched + with this CLI. Skills ship with the CLI and include docs workflows, + selector/block-id usage, XML/Markdown formats, and copy-paste examples. + + skills read lark-doc Docs workflow guide + skills read lark-doc Read a referenced docs skill file`, strings.TrimSpace(summary), skillReadCommand, skillReadCommand)) } diff --git a/shortcuts/doc/v2_only.go b/shortcuts/doc/v2_only.go new file mode 100644 index 00000000..e7485780 --- /dev/null +++ b/shortcuts/doc/v2_only.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +type docsLegacyFlag struct { + Name string + Replacement string +} + +func docsAPIVersionCompatFlag() common.Flag { + return common.Flag{ + Name: "api-version", + Desc: "deprecated compatibility flag; docs shortcuts always use v2, and both v1/v2 are accepted for rollback-safe skill examples", + Default: "v2", + } +} + +func docsCreateLegacyFlags() []docsLegacyFlag { + return []docsLegacyFlag{ + {Name: "title", Replacement: "put the title in --content, for example Title"}, + {Name: "markdown", Replacement: "use --content with --doc-format markdown"}, + {Name: "folder-token", Replacement: "use --parent-token"}, + {Name: "wiki-node", Replacement: "use --parent-token"}, + {Name: "wiki-space", Replacement: "use --parent-position my_library or a concrete parent position"}, + } +} + +func docsFetchLegacyFlags() []docsLegacyFlag { + return []docsLegacyFlag{ + {Name: "offset", Replacement: "use --scope outline/range/keyword/section for partial reads"}, + {Name: "limit", Replacement: "use --scope outline/range/keyword/section for partial reads"}, + } +} + +func docsUpdateLegacyFlags() []docsLegacyFlag { + return []docsLegacyFlag{ + {Name: "mode", Replacement: "use --command"}, + {Name: "markdown", Replacement: "use --content with --doc-format markdown"}, + {Name: "selection-with-ellipsis", Replacement: "use --command str_replace with --pattern"}, + {Name: "selection-by-title", Replacement: "fetch block ids first, then use --command block_replace/block_insert_after with --block-id"}, + {Name: "new-title", Replacement: "update the title through XML content in --content"}, + } +} + +func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag { + out := make([]common.Flag, 0, len(flags)) + for _, flag := range flags { + out = append(out, common.Flag{ + Name: flag.Name, + Desc: "deprecated v1 compatibility flag; run `lark-cli skills read lark-doc` for the v2 CLI skill", + Hidden: true, + }) + } + return out +} + +func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyFlags []docsLegacyFlag) error { + switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion { + case "", "v1", "v2": + default: + return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API") + } + + var used []string + var replacements []string + for _, flag := range legacyFlags { + if !runtime.Changed(flag.Name) { + continue + } + used = append(used, "--"+flag.Name) + if flag.Replacement != "" { + replacements = append(replacements, "--"+flag.Name+" -> "+flag.Replacement) + } + } + if len(used) == 0 { + return nil + } + + detail := "the old v1 interface has been shut down; legacy v1 flag(s) " + strings.Join(used, ", ") + " are no longer supported" + if len(replacements) > 0 { + detail += "; " + strings.Join(replacements, "; ") + } + return docsV2OnlyError(shortcut, detail) +} + +func docsV2OnlyError(shortcut, detail string) error { + return common.FlagErrorf( + "docs %s is v2-only; %s. Run `%s` for the current schema and examples. AI agents MUST read `%s` (XML) or `%s` (Markdown) and follow the latest format rules there. MUST NOT grep/open local SKILL.md files to discover this guidance; use `lark-cli skills read ...` so content stays version-matched with this CLI. Run `%s` for the latest command flags", + shortcut, + detail, + docsSkillReadCommandForShortcut(shortcut), + docsXMLSkillReadCommand, + docsMDSkillReadCommand, + docsHelpCommandForShortcut(shortcut), + ) +} diff --git a/shortcuts/doc/v2_only_test.go b/shortcuts/doc/v2_only_test.go new file mode 100644 index 00000000..ce216c8a --- /dev/null +++ b/shortcuts/doc/v2_only_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing.T) { + for _, apiVersion := range []string{"", "v1", "v2"} { + t.Run(apiVersion, func(t *testing.T) { + runtime := docsV2OnlyTestRuntime(t, apiVersion, false) + if err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}); err != nil { + t.Fatalf("validateDocsV2Only(%q) error = %v, want nil", apiVersion, err) + } + }) + } +} + +func TestValidateDocsV2OnlyRejectsUnknownAPIVersion(t *testing.T) { + runtime := docsV2OnlyTestRuntime(t, "v0", false) + err := validateDocsV2Only(runtime, "+fetch", nil) + if err == nil { + t.Fatal("expected unknown --api-version to be rejected") + } + for _, want := range []string{ + "docs +fetch is v2-only", + "--api-version is deprecated and only accepts v1 or v2", + "both values execute the v2 API", + "lark-cli skills read lark-doc references/lark-doc-fetch.md", + "lark-cli skills read lark-doc references/lark-doc-xml.md", + "lark-cli skills read lark-doc references/lark-doc-md.md", + "MUST NOT grep/open local SKILL.md files", + "lark-cli docs +fetch --help", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error missing %q: %v", want, err) + } + } +} + +func TestValidateDocsV2OnlyRejectsChangedLegacyFlags(t *testing.T) { + runtime := docsV2OnlyTestRuntime(t, "", true) + err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}) + if err == nil { + t.Fatal("expected changed legacy flag to be rejected") + } + for _, want := range []string{ + "the old v1 interface has been shut down", + "legacy v1 flag(s) --mode are no longer supported", + "--mode -> use --command", + "lark-cli skills read lark-doc references/lark-doc-update.md", + "lark-cli skills read lark-doc references/lark-doc-xml.md", + "lark-cli skills read lark-doc references/lark-doc-md.md", + "MUST NOT grep/open local SKILL.md files", + "lark-cli docs +update --help", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error missing %q: %v", want, err) + } + } +} + +func docsV2OnlyTestRuntime(t *testing.T, apiVersion string, legacyMode bool) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "+update"} + cmd.Flags().String("api-version", "", "") + cmd.Flags().String("mode", "", "") + if apiVersion != "" { + if err := cmd.Flags().Set("api-version", apiVersion); err != nil { + t.Fatalf("set api-version: %v", err) + } + } + if legacyMode { + if err := cmd.Flags().Set("mode", "overwrite"); err != nil { + t.Fatalf("set mode: %v", err) + } + } + return common.TestNewRuntimeContext(cmd, nil) +} diff --git a/shortcuts/doc/versioned_help.go b/shortcuts/doc/versioned_help.go deleted file mode 100644 index 8204006c..00000000 --- a/shortcuts/doc/versioned_help.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package doc - -import ( - "fmt" - - "github.com/larksuite/cli/internal/cmdutil" - "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 - } - }) - cmdutil.SetTips(cmd, docsTipsForVersion(ver)) - origHelp(cmd, args) - }) -} - -// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code -// path is used. -func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) { - fmt.Fprintf(runtime.IO().ErrOut, - "[deprecated] docs %s is using the v1 API. %s\n", - shortcut, docsV2VersionSelectionTips[0]) -} diff --git a/shortcuts/doc/versioned_help_test.go b/shortcuts/doc/versioned_help_test.go deleted file mode 100644 index 7957030e..00000000 --- a/shortcuts/doc/versioned_help_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package doc - -import ( - "strings" - "testing" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/shortcuts/common" -) - -func TestWarnDeprecatedV1SuggestsSkillUpdate(t *testing.T) { - for _, shortcut := range []string{"+create", "+fetch", "+update"} { - t.Run(shortcut, func(t *testing.T) { - f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{}) - warnDeprecatedV1(&common.RuntimeContext{Factory: f}, shortcut) - - got := stderr.String() - for _, want := range []string{ - "[deprecated] docs " + shortcut + " is using the v1 API.", - "Check the installed lark-doc skill first", - "if it is not the v2 skill, run `lark-cli update` to upgrade skills", - } { - if !strings.Contains(got, want) { - t.Fatalf("warning missing %q:\n%s", want, got) - } - } - if strings.Contains(got, "will be removed in a future release") { - t.Fatalf("warning should not include removal-only guidance:\n%s", got) - } - }) - } -} diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index baa69010..75918a72 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -155,7 +155,7 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) { } } -func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T) { +func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) { program := &cobra.Command{Use: "root"} RegisterShortcuts(program, newRegisterTestFactory(t)) @@ -166,121 +166,111 @@ func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T if docsCmd == nil || docsCmd.Name() != "docs" { t.Fatalf("docs command not mounted: %#v", docsCmd) } - if docsCmd.Flags().Lookup("api-version") == nil { - t.Fatal("docs command should expose --api-version for versioned help") + if docsCmd.Flags().Lookup("api-version") != nil { + t.Fatal("docs command should not expose service-level --api-version") } if !strings.Contains(docsCmd.Long, "Document and content operations.") { t.Fatalf("docs long help missing default description:\n%s", docsCmd.Long) } + for _, child := range docsCmd.Commands() { + if child.Name() == "+get-skill" { + t.Fatal("docs +get-skill should not be mounted") + } + } + var defaultHelp bytes.Buffer docsCmd.SetOut(&defaultHelp) if err := docsCmd.Help(); err != nil { t.Fatalf("docs help failed: %v", err) } for _, want := range []string{ - "Tips:", - "Docs v1 is deprecated and will be removed soon", - "Check the installed lark-doc skill first", - "if it is not the v2 skill, run `lark-cli update` to upgrade skills", - "After confirming lark-doc is v2", - "use `--api-version v2` with docs +create, docs +fetch, and docs +update", + "Start here (required for AI agents):", + "lark-cli skills read lark-doc", + "AI agents MUST read the matching embedded skill", + "Do not skip this step", + "MUST NOT grep/open local SKILL.md files", } { if !strings.Contains(defaultHelp.String(), want) { t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String()) } } -} - -func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) { - program := &cobra.Command{Use: "root"} - RegisterShortcuts(program, newRegisterTestFactory(t)) - - docsCmd, _, err := program.Find([]string{"docs"}) - if err != nil { - t.Fatalf("find docs command: %v", err) - } - if err := docsCmd.Flags().Set("api-version", "v2"); err != nil { - t.Fatalf("set docs api-version: %v", err) - } - - var out bytes.Buffer - docsCmd.SetOut(&out) - if err := docsCmd.Help(); err != nil { - t.Fatalf("docs v2 help failed: %v", err) - } - - for _, want := range []string{ - "Document and content operations (v2).", - "Tips:", - "Check the installed lark-doc skill first", - "if it is not the v2 skill, run `lark-cli update` to upgrade skills", - } { - if !strings.Contains(out.String(), want) { - t.Fatalf("docs v2 help missing %q:\n%s", want, out.String()) - } + if startIdx, usageIdx := strings.Index(defaultHelp.String(), "Start here (required for AI agents):"), strings.Index(defaultHelp.String(), "Usage:"); startIdx < 0 || usageIdx < 0 || startIdx > usageIdx { + t.Fatalf("docs help should show Start here before Usage:\n%s", defaultHelp.String()) } for _, unwanted := range []string{ + "Tips:", + "+get-skill", + "Docs shortcuts are v2-only", "Docs v1 is deprecated and will be removed soon", - "After confirming lark-doc is v2", - "use `--api-version v2` with docs +create, docs +fetch, and docs +update", + "lark-cli update", + "upgrade skills", + "Use --api-version v2 for the latest API", } { - if strings.Contains(out.String(), unwanted) { - t.Fatalf("docs v2 help should not include %q:\n%s", unwanted, out.String()) + if strings.Contains(defaultHelp.String(), unwanted) { + t.Fatalf("docs help should not include %q:\n%s", unwanted, defaultHelp.String()) } } } -func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) { +func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) { tests := []struct { - name string - shortcut string - apiVersion string - shortcutHelp string - versionedFlag string + name string + shortcut string + shortcutHelp string + visibleFlag string + skillCommand string + hiddenFlags []string + contentHelp []string + unwanted []string }{ { - name: "create v1", - shortcut: "+create", - apiVersion: "v1", - shortcutHelp: "Create a Lark document", - versionedFlag: "--markdown", + name: "create", + shortcut: "+create", + shortcutHelp: "Create a Lark document", + visibleFlag: "--content", + skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md", + hiddenFlags: []string{"title", "markdown", "folder-token", "wiki-node", "wiki-space"}, + contentHelp: []string{ + "AI agents MUST read", + "lark-cli skills read lark-doc references/lark-doc-xml.md", + "before writing any --content payload", + "when using --doc-format markdown, also read", + "lark-cli skills read lark-doc references/lark-doc-md.md", + "Follow the latest rules", + "MUST NOT grep/open local SKILL.md files", + "use --help for the latest command flags", + }, + unwanted: []string{"--markdown", "--title", "--folder-token", "--wiki-node", "--wiki-space"}, }, { - name: "create v2", - shortcut: "+create", - apiVersion: "v2", - shortcutHelp: "Create a Lark document", - versionedFlag: "--content", + name: "fetch", + shortcut: "+fetch", + shortcutHelp: "Fetch Lark document content", + visibleFlag: "read scope", + skillCommand: "lark-cli skills read lark-doc references/lark-doc-fetch.md", + hiddenFlags: []string{"offset", "limit"}, + unwanted: []string{"--offset", "--limit"}, }, { - name: "fetch v1", - shortcut: "+fetch", - apiVersion: "v1", - shortcutHelp: "Fetch Lark document content", - versionedFlag: "--offset", - }, - { - name: "fetch v2", - shortcut: "+fetch", - apiVersion: "v2", - shortcutHelp: "Fetch Lark document content", - versionedFlag: "partial read scope", - }, - { - name: "update v1", - shortcut: "+update", - apiVersion: "v1", - shortcutHelp: "Update a Lark document", - versionedFlag: "--mode", - }, - { - name: "update v2", - shortcut: "+update", - apiVersion: "v2", - shortcutHelp: "Update a Lark document", - versionedFlag: "--command", + name: "update", + shortcut: "+update", + shortcutHelp: "Update a Lark document", + visibleFlag: "--command", + skillCommand: "lark-cli skills read lark-doc references/lark-doc-update.md", + hiddenFlags: []string{"mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"}, + contentHelp: []string{ + "AI agents MUST read", + "lark-cli skills read lark-doc references/lark-doc-xml.md", + "before writing any --content payload", + "when using --doc-format markdown, also read", + "lark-cli skills read lark-doc references/lark-doc-md.md", + "Follow the latest rules", + "MUST NOT grep/open local SKILL.md files", + "use --help for the latest command flags", + }, + unwanted: []string{"--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"}, }, } @@ -296,8 +286,25 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) if cmd == nil || cmd.Name() != tt.shortcut { t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd) } - if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil { - t.Fatalf("set docs %s api-version: %v", tt.shortcut, err) + + for _, flagName := range tt.hiddenFlags { + flag := cmd.Flags().Lookup(flagName) + if flag == nil { + t.Fatalf("docs %s missing hidden compatibility flag %q", tt.shortcut, flagName) + } + if !flag.Hidden { + t.Fatalf("docs %s flag %q should be hidden", tt.shortcut, flagName) + } + } + apiVersionFlag := cmd.Flags().Lookup("api-version") + if apiVersionFlag == nil { + t.Fatalf("docs %s missing --api-version flag", tt.shortcut) + } + if apiVersionFlag.Hidden { + t.Fatalf("docs %s --api-version should be visible", tt.shortcut) + } + if apiVersionFlag.DefValue != "v2" { + t.Fatalf("docs %s --api-version default = %q, want v2", tt.shortcut, apiVersionFlag.DefValue) } var out bytes.Buffer @@ -306,49 +313,39 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) t.Fatalf("docs %s help failed: %v", tt.shortcut, err) } - wantTips := []string{ - "Tips:", - "Docs v1 is deprecated and will be removed soon", - "Check the installed lark-doc skill first", - "if it is not the v2 skill, run `lark-cli update` to upgrade skills", - "After confirming lark-doc is v2", - "use `--api-version v2` with docs +create, docs +fetch, and docs +update", - } - unwantedTips := []string{ - "[NOTE]", - "Use --api-version v2 for the latest API", - "otherwise use the default v1 flags", - "legacy v1 examples and flags", - } - if tt.apiVersion == "v2" { - wantTips = []string{ - "Tips:", - "Check the installed lark-doc skill first", - "if it is not the v2 skill, run `lark-cli update` to upgrade skills", - } - unwantedTips = append(unwantedTips, - "Docs v1 is deprecated and will be removed soon", - "After confirming lark-doc is v2", - "use `--api-version v2` with docs +create, docs +fetch, and docs +update", - ) - } - for _, want := range []string{ tt.shortcutHelp, - tt.versionedFlag, + tt.visibleFlag, + "--api-version", + "deprecated compatibility flag; docs shortcuts always use v2", + "both v1/v2 are accepted", + "(default \"v2\")", + "Start here (required for AI agents):", + "AI agents MUST read the matching embedded skill", + "Do not skip this step", + "MUST NOT grep/open local SKILL.md files", + tt.skillCommand, } { if !strings.Contains(out.String(), want) { - t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String()) + t.Fatalf("docs %s help missing %q:\n%s", tt.shortcut, want, out.String()) } } - for _, want := range wantTips { + for _, want := range tt.contentHelp { if !strings.Contains(out.String(), want) { - t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String()) + t.Fatalf("docs %s content help missing %q:\n%s", tt.shortcut, want, out.String()) } } - for _, unwanted := range unwantedTips { + if startIdx, usageIdx := strings.Index(out.String(), "Start here (required for AI agents):"), strings.Index(out.String(), "Usage:"); startIdx < 0 || usageIdx < 0 || startIdx > usageIdx { + t.Fatalf("docs %s help should show Start here before Usage:\n%s", tt.shortcut, out.String()) + } + for _, unwanted := range []string{"Tips:", "+get-skill", "Docs shortcuts are v2-only"} { if strings.Contains(out.String(), unwanted) { - t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String()) + t.Fatalf("docs %s help should not include %q:\n%s", tt.shortcut, unwanted, out.String()) + } + } + for _, unwanted := range tt.unwanted { + if strings.Contains(out.String(), unwanted) { + t.Fatalf("docs %s help should not include %q:\n%s", tt.shortcut, unwanted, out.String()) } } }) diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 3a5991c0..31142ee8 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -1,6 +1,7 @@ --- name: lark-doc -description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" +version: 2.0.0 +description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。默认使用 DocxXML,也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" metadata: requires: bins: ["lark-cli"] diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md index 5eb1254f..5d7fa5f0 100644 --- a/skills/lark-doc/references/lark-doc-create.md +++ b/skills/lark-doc/references/lark-doc-create.md @@ -1,10 +1,9 @@ # docs +create(创建飞书云文档) > **前置条件(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、并行执行策略) +> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)) +> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义) +> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略) > > **未读完以上文件就生成内容会导致格式错误或样式不达标。** @@ -85,4 +84,3 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项 - [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档 - [`lark-doc-update.md`](lark-doc-update.md) — 更新文档 - [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档 -- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md index 53b7b99c..08f89cc4 100644 --- a/skills/lark-doc/references/lark-doc-fetch.md +++ b/skills/lark-doc/references/lark-doc-fetch.md @@ -1,8 +1,6 @@ # docs +fetch(获取飞书云文档) -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - ## 命令 ```bash @@ -136,5 +134,4 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \ - [lark-doc-create](lark-doc-create.md) — 创建文档 - [lark-doc-update](lark-doc-update.md) — 更新文档 - [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材 -- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 +- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图 \ No newline at end of file diff --git a/skills/lark-doc/references/lark-doc-md.md b/skills/lark-doc/references/lark-doc-md.md index de547d1f..88e4e542 100644 --- a/skills/lark-doc/references/lark-doc-md.md +++ b/skills/lark-doc/references/lark-doc-md.md @@ -69,3 +69,7 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下 非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。 > **⚠️ XML 标签会被解析并生效**:即使在 `--doc-format markdown` 下,``、``、`` 等 XML 标签也会被识别为对应的富文本节点,**不会**按字面量显示。如需字面量输出尖括号包裹的文本(例如示例中的 ``),必须转义左尖括号:`\`、`\`。 + +## 参考 + +- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范 \ No newline at end of file diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md index 7eeb13a9..2444d5bc 100644 --- a/skills/lark-doc/references/lark-doc-update.md +++ b/skills/lark-doc/references/lark-doc-update.md @@ -2,10 +2,9 @@ # docs +update(更新飞书云文档) > **前置条件(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、并行执行策略) +> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)) +> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义) +> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略) > > **未读完以上文件就生成内容会导致格式错误或样式不达标。** @@ -249,4 +248,3 @@ lark-cli docs +update --api-version v2 --doc "" --command str_replace \ - [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档 - [`lark-doc-create.md`](lark-doc-create.md) — 创建文档 - [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档 -- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md index 4feef51b..b19b7b85 100644 --- a/tests/cli_e2e/docs/coverage.md +++ b/tests/cli_e2e/docs/coverage.md @@ -9,6 +9,7 @@ - TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`. - TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token. - TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes. +- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance. - Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here. - Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration. @@ -16,11 +17,11 @@ | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | -| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user | `--folder-token`; `--title`; `--markdown` | helper asserts returned doc id | -| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user | `--doc ` | | +| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create | `--parent-token`; `--doc-format markdown`; `--content` | helper asserts returned doc id from `data.document.document_id` | +| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc `; `--doc-format markdown` | | | ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet | | ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions | | ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture | | ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E | -| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot | `--doc`; `--mode overwrite`; `--markdown`; `--new-title` | | +| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content` | | | ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions | diff --git a/tests/cli_e2e/docs/docs_create_fetch_test.go b/tests/cli_e2e/docs/docs_create_fetch_test.go index 0d9f7a68..cce0fc52 100644 --- a/tests/cli_e2e/docs/docs_create_fetch_test.go +++ b/tests/cli_e2e/docs/docs_create_fetch_test.go @@ -41,12 +41,15 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) { Args: []string{ "docs", "+fetch", "--doc", docToken, + "--doc-format", "markdown", }, }) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) - assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String()) + content := gjson.Get(result.Stdout, "data.document.content").String() + assert.Contains(t, content, docTitle) + assert.Contains(t, content, "This document was created by lark-cli e2e test.") }) } @@ -73,12 +76,14 @@ func TestDocs_CreateAndFetchWorkflowAsUser(t *testing.T) { require.NotEmpty(t, docToken, "document token should be created before fetch") result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"docs", "+fetch", "--doc", docToken}, + Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"}, DefaultAs: defaultAs, }) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) - assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String()) + content := gjson.Get(result.Stdout, "data.document.content").String() + assert.Contains(t, content, docTitle) + assert.Contains(t, content, "Created with user access token.") }) } diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go index 6e0f8cce..8d1a4475 100644 --- a/tests/cli_e2e/docs/docs_update_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go @@ -13,20 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestDocs_UpdateDryRunSuppressesSemanticWarnings asserts the contract that -// docsUpdateWarnings is NOT invoked on the --dry-run path. The unit tests in -// shortcuts/doc/docs_update_check_test.go prove the helper emits warnings for -// replace_range + blank-line and for combined-emphasis markers; this E2E -// locks in that they never reach the user during dry-run planning, so a -// future refactor that moves warning emission into a shared code path can't -// silently regress. -// -// Input is intentionally crafted to trigger BOTH warnings the helper emits: -// - mode=replace_range + markdown containing "\n\n" (blank-line warning) -// - markdown containing `***combined***` (combined bold+italic warning) -// -// Neither string may appear in dry-run output. -func TestDocs_UpdateDryRunSuppressesSemanticWarnings(t *testing.T) { +func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { // Fake creds are enough — dry-run short-circuits before any real API call. t.Setenv("LARKSUITE_CLI_APP_ID", "app") t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") @@ -35,36 +22,76 @@ func TestDocs_UpdateDryRunSuppressesSemanticWarnings(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) t.Cleanup(cancel) - // "***combined***" is a triple-asterisk combined-emphasis shape; "\n\n" - // is a paragraph break. Both would normally produce warnings when - // Execute runs under --mode=replace_range; both must be absent here. - markdown := "***combined***\n\nsecond paragraph" - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{ - "docs", "+update", - "--doc", "doxcnDryRunE2E", - "--mode", "replace_range", - "--selection-with-ellipsis", "placeholder", - "--markdown", markdown, - "--dry-run", + tests := []struct { + name string + args []string + wantURL string + }{ + { + name: "create", + args: []string{ + "docs", "+create", + "--content", "Dry Run

hello

", + "--dry-run", + }, + wantURL: "/open-apis/docs_ai/v1/documents", }, - DefaultAs: "bot", - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) + { + name: "create api-version v1 compatibility", + args: []string{ + "docs", "+create", + "--api-version", "v1", + "--content", "Dry Run

hello

", + "--dry-run", + }, + wantURL: "/open-apis/docs_ai/v1/documents", + }, + { + name: "fetch", + args: []string{ + "docs", "+fetch", + "--doc", "doxcnDryRunE2E", + "--dry-run", + }, + wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch", + }, + { + name: "update", + args: []string{ + "docs", "+update", + "--doc", "doxcnDryRunE2E", + "--command", "append", + "--content", "

hello

", + "--dry-run", + }, + wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E", + }, + } - // Neither warning prefix ("warning:") nor either specific warning body - // may appear in dry-run output (stdout OR stderr). - combined := result.Stdout + "\n" + result.Stderr - for _, needle := range []string{ - "warning:", - "does not split a block into multiple paragraphs", - "combined bold+italic markers", - } { - if strings.Contains(combined, needle) { - t.Errorf("dry-run output must not surface pre-write warning %q\nstdout:\n%s\nstderr:\n%s", - needle, result.Stdout, result.Stderr) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + combined := result.Stdout + "\n" + result.Stderr + for _, want := range []string{ + tt.wantURL, + "docs_ai/v1", + } { + if !strings.Contains(combined, want) { + t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr) + } + } + if strings.Contains(combined, "/mcp") || strings.Contains(combined, "MCP tool") { + t.Fatalf("dry-run output should not use MCP\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } + if strings.Contains(combined, "--api-version") { + t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } + }) } } diff --git a/tests/cli_e2e/docs/docs_update_test.go b/tests/cli_e2e/docs/docs_update_test.go index 0dfaccd1..03e3502a 100644 --- a/tests/cli_e2e/docs/docs_update_test.go +++ b/tests/cli_e2e/docs/docs_update_test.go @@ -43,9 +43,9 @@ func TestDocs_UpdateWorkflow(t *testing.T) { Args: []string{ "docs", "+update", "--doc", docToken, - "--mode", "overwrite", - "--markdown", updatedContent, - "--new-title", updatedTitle, + "--command", "overwrite", + "--doc-format", "markdown", + "--content", "# " + updatedTitle + "\n\n" + updatedContent, }, DefaultAs: defaultAs, }) @@ -61,12 +61,15 @@ func TestDocs_UpdateWorkflow(t *testing.T) { Args: []string{ "docs", "+fetch", "--doc", docToken, + "--doc-format", "markdown", }, DefaultAs: defaultAs, }) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) - assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String()) + content := gjson.Get(result.Stdout, "data.document.content").String() + assert.Contains(t, content, updatedTitle) + assert.Contains(t, content, "This is the updated content.") }) } diff --git a/tests/cli_e2e/docs/helpers_test.go b/tests/cli_e2e/docs/helpers_test.go index eb2e793c..03d41abf 100644 --- a/tests/cli_e2e/docs/helpers_test.go +++ b/tests/cli_e2e/docs/helpers_test.go @@ -21,9 +21,9 @@ func createDocWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, f result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{ "docs", "+create", - "--folder-token", folderToken, - "--title", title, - "--markdown", markdown, + "--parent-token", folderToken, + "--doc-format", "markdown", + "--content", "# " + title + "\n\n" + markdown, }, DefaultAs: defaultAs, }) @@ -31,7 +31,7 @@ func createDocWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, f result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) - docToken := gjson.Get(result.Stdout, "data.doc_id").String() + docToken := gjson.Get(result.Stdout, "data.document.document_id").String() require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) parentT.Cleanup(func() {