diff --git a/shortcuts/doc/doc_errors_test.go b/shortcuts/doc/doc_errors_test.go
index 62ace6582d..a7a5f5d7cd 100644
--- a/shortcuts/doc/doc_errors_test.go
+++ b/shortcuts/doc/doc_errors_test.go
@@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) {
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
wantParam: "--pattern",
},
+ {
+ name: "XML str_replace rejects multiline pattern",
+ str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
+ wantParam: "--pattern",
+ },
{
name: "block_delete without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
wantParam: "--block-id",
},
+ {
+ name: "block_delete rejects empty ID",
+ str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
+ wantParam: "--block-id",
+ },
+ {
+ name: "block_delete rejects duplicate ID",
+ str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
+ wantParam: "--block-id",
+ },
{
name: "block_insert_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go
index f5002ebb13..97d5443a2b 100644
--- a/shortcuts/doc/docs_create_test.go
+++ b/shortcuts/doc/docs_create_test.go
@@ -17,6 +17,46 @@ import (
// ── V2 (OpenAPI) tests ──
+func TestStripTopLevelXMLTitles(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ content string
+ want string
+ }{
+ {
+ name: "single title",
+ content: "
Content titlebody
",
+ want: "body
",
+ },
+ {
+ name: "multiple titles",
+ content: "First\nbody
\nSecond",
+ want: "body
",
+ },
+ {
+ name: "nested title is preserved",
+ content: "Nestedbody
",
+ want: "Nestedbody
",
+ },
+ {
+ name: "malformed XML is preserved",
+ content: "Content titleA & B
",
+ want: "Content titleA & B
",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
+ t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go
index e5c5e36724..1a594dc10a 100644
--- a/shortcuts/doc/docs_create_v2.go
+++ b/shortcuts/doc/docs_create_v2.go
@@ -7,6 +7,8 @@ import (
"bytes"
"context"
"encoding/xml"
+ "errors"
+ "io"
"strings"
"github.com/larksuite/cli/errs"
@@ -16,7 +18,7 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
- {Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as ... so the title wins over later content titles"},
+ {Name: "title", Desc: "document title; the CLI prepends it to --content as .... In XML mode, top-level elements in --content are removed so this flag wins without duplicate-title warnings"},
{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: "reference-map", Desc: docsReferenceMapFlagDesc, 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"}},
@@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
if title == "" {
return content
}
+ if runtime.Str("doc-format") == "xml" {
+ content = stripTopLevelXMLTitles(content)
+ }
titleTag := "" + escapeDocTitleText(title) + ""
if content == "" {
@@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
return titleTag + "\n" + content
}
+type docContentRange struct {
+ start int64
+ end int64
+}
+
+// stripTopLevelXMLTitles preserves the established --title-wins contract while
+// avoiding duplicate-title warnings from XML content. If the fragment is not
+// well-formed XML, it is left untouched for the service to diagnose.
+func stripTopLevelXMLTitles(content string) string {
+ const wrapperStart = ""
+ wrapped := wrapperStart + content + ""
+ decoder := xml.NewDecoder(strings.NewReader(wrapped))
+ wrapperLen := int64(len(wrapperStart))
+ depth := 0
+ activeStart := int64(-1)
+ ranges := make([]docContentRange, 0, 1)
+
+ for {
+ tokenStart := decoder.InputOffset()
+ token, err := decoder.Token()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return content
+ }
+
+ switch value := token.(type) {
+ case xml.StartElement:
+ if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
+ activeStart = tokenStart - wrapperLen
+ }
+ depth++
+ case xml.EndElement:
+ depth--
+ if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
+ ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
+ activeStart = -1
+ }
+ }
+ }
+
+ if len(ranges) == 0 {
+ return content
+ }
+
+ var result strings.Builder
+ cursor := int64(0)
+ for _, item := range ranges {
+ result.WriteString(content[int(cursor):int(item.start)])
+ cursor = item.end
+ }
+ result.WriteString(content[int(cursor):])
+ return strings.TrimSpace(result.String())
+}
+
func escapeDocTitleText(title string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(title))
diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go
index 755b35c70f..987dca2bf9 100644
--- a/shortcuts/doc/docs_update_v2.go
+++ b/shortcuts/doc/docs_update_v2.go
@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
{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: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, 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 block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
+ {Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
+ {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -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"},
}
@@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
if pattern == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
}
+ if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
+ }
case "block_delete":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
}
+ if err := validateBlockDeleteIDs(blockID); err != nil {
+ return err
+ }
case "block_insert_after":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
@@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return nil
}
+func validateBlockDeleteIDs(raw string) error {
+ seen := make(map[string]struct{})
+ for _, part := range strings.Split(raw, ",") {
+ blockID := strings.TrimSpace(part)
+ if blockID == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
+ }
+ if _, ok := seen[blockID]; ok {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
+ }
+ seen[blockID] = struct{}{}
+ }
+ return nil
+}
+
+func normalizeBlockDeleteIDs(raw string) string {
+ parts := strings.Split(raw, ",")
+ for i := range parts {
+ parts[i] = strings.TrimSpace(parts[i])
+ }
+ return strings.Join(parts, ",")
+}
+
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
@@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
body["pattern"] = v
}
if blockID != "" {
+ if cmd == "block_delete" {
+ blockID = normalizeBlockDeleteIDs(blockID)
+ }
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {
diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go
index 5219fa9f5a..33ba745499 100644
--- a/tests/cli_e2e/docs/docs_update_dryrun_test.go
+++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go
@@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "block_delete",
- "--block-id", "blkA,blkB,blkC",
+ "--block-id", "blkA, blkB, blkC",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
+ wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
},
{
name: "history list",
@@ -225,3 +226,60 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "Dry Run & Title\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
}
+
+func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_APP_ID", "app")
+ t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
+ t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ t.Cleanup(cancel)
+
+ result, err := clie2e.RunCmd(ctx, clie2e.Request{
+ Args: []string{
+ "docs", "+create",
+ "--title", "Flag title",
+ "--content", "Content titlebody
",
+ "--dry-run",
+ },
+ DefaultAs: "bot",
+ })
+ require.NoError(t, err)
+ result.AssertExitCode(t, 0)
+ require.Equal(t, "Flag title\nbody
", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
+}
+
+func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_APP_ID", "app")
+ t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
+ t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ t.Cleanup(cancel)
+
+ tests := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {
+ name: "multiline XML str_replace",
+ args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
+ want: "must be inline",
+ },
+ {
+ name: "duplicate block delete ID",
+ args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
+ want: "duplicate ID",
+ },
+ }
+
+ 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, 2)
+ require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
+ })
+ }
+}