feat: add markdown +patch shortcut (#857)

* feat: add markdown +patch shortcut

Change-Id: I8159941ff9dec4e5cbf0c757ec19ee172b302224

* fix: align markdown patch validation and dry-run

Change-Id: I98079901e980b74998938afc4917b91a79689948
This commit is contained in:
wangweiming-01
2026-05-18 20:54:11 +08:00
committed by GitHub
parent 67b16c5ec3
commit de00343063
16 changed files with 1094 additions and 24 deletions

View File

@@ -28,7 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
| 📝 Markdown | Create, fetch, patch, and overwrite Drive-native `.md` files |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
@@ -132,7 +132,7 @@ lark-cli auth status
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
| `lark-markdown` | Create, fetch, patch, and overwrite Drive-native Markdown files |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |

View File

@@ -28,7 +28,7 @@
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -133,7 +133,7 @@ lark-cli auth status
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |

View File

@@ -33,6 +33,26 @@ func GetFloat(m map[string]interface{}, keys ...string) float64 {
return f
}
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.
func GetInt(m map[string]interface{}, keys ...string) int {
if len(keys) == 0 {
return 0
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return 0
}
switch n := v[keys[len(keys)-1]].(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}
// GetBool safely extracts a bool.
func GetBool(m map[string]interface{}, keys ...string) bool {
if len(keys) == 0 {

View File

@@ -64,6 +64,32 @@ func TestGetFloat(t *testing.T) {
}
}
func TestGetInt(t *testing.T) {
m := map[string]interface{}{
"count": 42,
"json_count": 7.0,
"data": map[string]interface{}{
"score": int64(99),
},
}
if got := GetInt(m, "count"); got != 42 {
t.Errorf("GetInt(count) = %d, want 42", got)
}
if got := GetInt(m, "json_count"); got != 7 {
t.Errorf("GetInt(json_count) = %d, want 7", got)
}
if got := GetInt(m, "data", "score"); got != 99 {
t.Errorf("GetInt(data.score) = %d, want 99", got)
}
if got := GetInt(m, "missing"); got != 0 {
t.Errorf("GetInt(missing) = %d, want 0", got)
}
if got := GetInt(m); got != 0 {
t.Errorf("GetInt() = %d, want 0", got)
}
}
func TestGetBool(t *testing.T) {
m := map[string]interface{}{
"active": true,

View File

@@ -5,6 +5,7 @@ package markdown
import (
"bytes"
"context"
"errors"
"fmt"
"io"
@@ -112,6 +113,42 @@ func finalMarkdownFileName(spec markdownUploadSpec) string {
return filepath.Base(spec.FilePath)
}
func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markdownUploadSpec) (string, error) {
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = filepath.Base(spec.FilePath)
}
if fileName == "" {
remoteName, err := fetchMarkdownFileName(runtime, spec.FileToken)
if err != nil {
return "", err
}
fileName = strings.TrimSpace(remoteName)
}
if fileName == "" {
fileName = spec.FileToken + ".md"
}
return fileName, nil
}
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return nil, output.ErrNetwork("download failed: %s", err)
}
return resp, nil
}
func validateNonEmptyMarkdownSize(size int64) error {
if size == 0 {
return output.ErrValidation("%s", markdownEmptyContentError)
}
return nil
}
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
var size int64
if spec.ContentSet {
@@ -127,8 +164,8 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
}
size = info.Size()
}
if size == 0 {
return 0, output.ErrValidation("%s", markdownEmptyContentError)
if err := validateNonEmptyMarkdownSize(size); err != nil {
return 0, err
}
return size, nil
}

View File

@@ -6,7 +6,6 @@ package markdown
import (
"context"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -73,19 +72,9 @@ var MarkdownOverwrite = common.Shortcut{
return err
}
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = filepath.Base(spec.FilePath)
}
if fileName == "" {
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
if err != nil {
return err
}
fileName = strings.TrimSpace(remoteName)
}
if fileName == "" {
fileName = fileToken + ".md"
fileName, err := resolveMarkdownOverwriteFileName(runtime, spec)
if err != nil {
return err
}
spec.FileName = fileName

View File

@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"fmt"
"io"
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
markdownPatchModeLiteral = "literal"
markdownPatchModeRegex = "regex"
)
type markdownPatchSpec struct {
FileToken string
Pattern string
Content string
ContentSet bool
Regex bool
}
var MarkdownPatch = common.Shortcut{
Service: "markdown",
Command: "+patch",
Description: "Patch a Markdown file in Drive via fetch-local-replace-overwrite",
Risk: "write",
Scopes: []string{"drive:file:download", "drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target Markdown file token", Required: true},
{Name: "pattern", Desc: "literal text or RE2 regex to match", Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "replacement Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "regex", Type: "bool", Desc: "interpret --pattern as RE2 regular expression"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newMarkdownPatchSpec(runtime)
if err := validateMarkdownPatchSpec(runtime, spec); err != nil {
return err
}
if spec.Regex {
if _, err := regexp.Compile(spec.Pattern); err != nil {
return output.ErrValidation("invalid --pattern regex: %s", err)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newMarkdownPatchSpec(runtime)
mode := markdownPatchModeLiteral
if spec.Regex {
mode = markdownPatchModeRegex
}
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
return common.NewDryRunAPI().
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the current Markdown content").
Set("file_token", spec.FileToken).
POST("/open-apis/drive/v1/metas/batch_query").
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": spec.FileToken,
"doc_type": "file",
},
},
}).
POST("/open-apis/drive/v1/files/upload_all").
Desc("[3a] If the patched Markdown is at most "+sizeThreshold+", overwrite the file with multipart/form-data upload_all").
Body(map[string]interface{}{
"file_name": "<existing_remote_name_or_" + spec.FileToken + ".md>",
"parent_type": "explorer",
"parent_node": "",
"size": "<updated_size_bytes>",
"file": "<patched_markdown_content>",
"file_token": spec.FileToken,
}).
POST("/open-apis/drive/v1/files/upload_prepare").
Desc("[3b] If the patched Markdown exceeds "+sizeThreshold+", initialize multipart overwrite upload").
Body(map[string]interface{}{
"file_name": "<existing_remote_name_or_" + spec.FileToken + ".md>",
"parent_type": "explorer",
"parent_node": "",
"size": "<updated_size_bytes>",
"file_token": spec.FileToken,
}).
POST("/open-apis/drive/v1/files/upload_part").
Desc("[3c] Upload file parts (repeated) when multipart overwrite is required").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/files/upload_finish").
Desc("[3d] Finalize multipart overwrite upload and return the new version").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
}).
Set("mode", mode)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newMarkdownPatchSpec(runtime)
resp, err := openMarkdownDownload(ctx, runtime, spec.FileToken)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
original := string(payload)
patched, matchCount, err := applyMarkdownPatch(original, spec)
if err != nil {
return err
}
mode := markdownPatchModeLiteral
if spec.Regex {
mode = markdownPatchModeRegex
}
out := map[string]interface{}{
"updated": false,
"mode": mode,
"match_count": matchCount,
"version": "",
"size_bytes_before": len(payload),
"size_bytes_after": len(payload),
}
if matchCount == 0 {
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownPatch(w, out)
})
return nil
}
patchedPayload := []byte(patched)
if err := validateNonEmptyMarkdownSize(int64(len(patchedPayload))); err != nil {
return err
}
specUpload := markdownUploadSpec{
FileToken: spec.FileToken,
}
fileName, err := resolveMarkdownOverwriteFileName(runtime, specUpload)
if err != nil {
return err
}
specUpload.FileName = fileName
result, err := uploadMarkdownContent(runtime, specUpload, patchedPayload)
if err != nil {
return err
}
out["updated"] = true
out["version"] = result.Version
out["size_bytes_after"] = len(patchedPayload)
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownPatch(w, out)
})
return nil
},
}
func newMarkdownPatchSpec(runtime *common.RuntimeContext) markdownPatchSpec {
return markdownPatchSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Pattern: runtime.Str("pattern"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
Regex: runtime.Bool("regex"),
}
}
func validateMarkdownPatchSpec(runtime *common.RuntimeContext, spec markdownPatchSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if !runtime.Changed("pattern") {
return common.FlagErrorf("--pattern is required")
}
if spec.Pattern == "" {
return output.ErrValidation("--pattern cannot be empty")
}
if !spec.ContentSet {
return common.FlagErrorf("--content is required")
}
return nil
}
func applyMarkdownPatch(original string, spec markdownPatchSpec) (string, int, error) {
if !spec.Regex {
return strings.ReplaceAll(original, spec.Pattern, spec.Content), strings.Count(original, spec.Pattern), nil
}
re, err := regexp.Compile(spec.Pattern)
if err != nil {
return "", 0, output.ErrValidation("invalid --pattern regex: %s", err)
}
matches := re.FindAllStringIndex(original, -1)
return re.ReplaceAllString(original, spec.Content), len(matches), nil
}
func prettyPrintMarkdownPatch(w io.Writer, data map[string]interface{}) {
updated := common.GetBool(data, "updated")
if updated {
io.WriteString(w, "updated: true\n")
} else {
io.WriteString(w, "updated: false\n")
}
io.WriteString(w, "mode: "+common.GetString(data, "mode")+"\n")
fmt.Fprintf(w, "match_count: %d\n", common.GetInt(data, "match_count"))
if version := common.GetString(data, "version"); version != "" {
io.WriteString(w, "version: "+version+"\n")
}
fmt.Fprintf(w, "size_bytes_before: %d\n", common.GetInt(data, "size_bytes_before"))
fmt.Fprintf(w, "size_bytes_after: %d\n", common.GetInt(data, "size_bytes_after"))
}

View File

@@ -0,0 +1,564 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestMarkdownPatchValidation(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
tests := []struct {
name string
args []string
want string
}{
{
name: "pattern is required",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--content", "DONE",
},
want: "--pattern is required",
},
{
name: "pattern cannot be empty",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "",
"--content", "DONE",
},
want: "--pattern cannot be empty",
},
{
name: "content is required",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
},
want: "--content is required",
},
{
name: "invalid regex",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--regex",
"--pattern", "(",
"--content", "DONE",
},
want: "invalid --pattern regex",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := mountAndRunMarkdown(t, MarkdownPatch, tt.args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("expected error containing %q, got %v", tt.want, err)
}
})
}
}
func TestMarkdownPatchDryRunLiteral(t *testing.T) {
dry := decodeMarkdownPatchDryRun(t, "box_md_patch", "TODO", "DONE", false)
if got := dry.Mode; got != markdownPatchModeLiteral {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral)
}
if got := len(dry.API); got != 6 {
t.Fatalf("api steps = %d, want 6", got)
}
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
t.Fatalf("download url = %q", got)
}
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metas url = %q", got)
}
if got := dry.API[2].URL; got != "/open-apis/drive/v1/files/upload_all" {
t.Fatalf("upload_all url = %q", got)
}
if got := dry.API[3].URL; got != "/open-apis/drive/v1/files/upload_prepare" {
t.Fatalf("upload_prepare url = %q", got)
}
if got := dry.API[4].URL; got != "/open-apis/drive/v1/files/upload_part" {
t.Fatalf("upload_part url = %q", got)
}
if got := dry.API[5].URL; got != "/open-apis/drive/v1/files/upload_finish" {
t.Fatalf("upload_finish url = %q", got)
}
if got := dry.API[2].Body["file_token"]; got != "box_md_patch" {
t.Fatalf("upload_all file_token = %#v", got)
}
if got := dry.API[3].Body["file_token"]; got != "box_md_patch" {
t.Fatalf("upload_prepare file_token = %#v", got)
}
if got := dry.API[2].Body["file"]; got != "<patched_markdown_content>" {
t.Fatalf("upload_all file placeholder = %#v", got)
}
}
func TestMarkdownPatchDryRunRegex(t *testing.T) {
dry := decodeMarkdownPatchDryRun(t, "box_md_patch", `Version: ([0-9]+)`, `Version: $1`, true)
if got := dry.Mode; got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
t.Fatalf("download desc = %q", got)
}
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
t.Fatalf("upload_prepare desc = %q", got)
}
if got := dry.API[5].Body["block_num"]; got != "<block_num>" {
t.Fatalf("upload_finish block_num = %#v", got)
}
}
func TestValidateMarkdownPatchSpecRejectsInvalidFileToken(t *testing.T) {
runtime := newMarkdownPatchRuntime(t, "../bad", "TODO", "DONE", false)
err := validateMarkdownPatchSpec(runtime, newMarkdownPatchSpec(runtime))
if err == nil || !strings.Contains(err.Error(), "--file-token must not contain '..' path traversal") {
t.Fatalf("expected invalid file-token error, got %v", err)
}
}
func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeMarkdownEnvelope(t, stdout)
if common.GetBool(data, "updated") {
t.Fatalf("updated = true, want false")
}
if got := common.GetString(data, "mode"); got != markdownPatchModeLiteral {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral)
}
if got := common.GetInt(data, "match_count"); got != 0 {
t.Fatalf("match_count = %d, want 0", got)
}
if got := common.GetString(data, "version"); got != "" {
t.Fatalf("version = %q, want empty", got)
}
if got := common.GetInt(data, "size_bytes_before"); got != len("# hello\n") {
t.Fatalf("size_bytes_before = %d, want %d", got, len("# hello\n"))
}
if got := common.GetInt(data, "size_bytes_after"); got != len("# hello\n") {
t.Fatalf("size_bytes_after = %d, want %d", got, len("# hello\n"))
}
if strings.Contains(stdout.String(), `"matches"`) {
t.Fatalf("stdout should not include matches field: %s", stdout.String())
}
}
func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"updated: false",
"mode: literal",
"match_count: 0",
"size_bytes_before: 8",
"size_bytes_after: 8",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "version:") {
t.Fatalf("pretty output should omit version when unchanged:\n%s", out)
}
}
func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# TODO\nTODO\n"),
Headers: map[string][]string{
"Content-Disposition": {`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "README.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910626",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != "box_md_patch" {
t.Fatalf("file_token = %q, want box_md_patch", got)
}
if got := body.Fields["file_name"]; got != "README.md" {
t.Fatalf("file_name = %q, want README.md", got)
}
if got := string(body.Files["file"]); got != "# DONE\nDONE\n" {
t.Fatalf("uploaded file content = %q", got)
}
data := decodeMarkdownEnvelope(t, stdout)
if !common.GetBool(data, "updated") {
t.Fatalf("updated = false, want true")
}
if got := common.GetInt(data, "match_count"); got != 2 {
t.Fatalf("match_count = %d, want 2", got)
}
if got := common.GetString(data, "version"); got != "7633658129540910626" {
t.Fatalf("version = %q, want 7633658129540910626", got)
}
if got := common.GetInt(data, "size_bytes_before"); got != len("# TODO\nTODO\n") {
t.Fatalf("size_bytes_before = %d, want %d", got, len("# TODO\nTODO\n"))
}
if got := common.GetInt(data, "size_bytes_after"); got != len("# DONE\nDONE\n") {
t.Fatalf("size_bytes_after = %d, want %d", got, len("# DONE\nDONE\n"))
}
}
func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# TODO\n"),
Headers: map[string][]string{
"Content-Disposition": {`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "README.md"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "9001",
},
},
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"updated: true",
"mode: literal",
"match_count: 1",
"version: 9001",
"size_bytes_before: 7",
"size_bytes_after: 7",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
}
func TestMarkdownPatchRegexOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("Version: 12\nVersion: 34\n"),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "version.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910627",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--regex",
"--pattern", `Version: ([0-9]+)`,
"--content", `Version: $1 (patched)`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := string(body.Files["file"]); got != "Version: 12 (patched)\nVersion: 34 (patched)\n" {
t.Fatalf("uploaded file content = %q", got)
}
data := decodeMarkdownEnvelope(t, stdout)
if got := common.GetString(data, "mode"); got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := common.GetInt(data, "match_count"); got != 2 {
t.Fatalf("match_count = %d, want 2", got)
}
}
func TestApplyMarkdownPatchRejectsInvalidRegex(t *testing.T) {
_, _, err := applyMarkdownPatch("hello", markdownPatchSpec{
Pattern: "(",
Content: "DONE",
Regex: true,
})
if err == nil || !strings.Contains(err.Error(), "invalid --pattern regex") {
t.Fatalf("expected invalid regex error, got %v", err)
}
}
func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("hello world\n"),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "hello.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910628",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", " world",
"--content", "",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := string(body.Files["file"]); got != "hello\n" {
t.Fatalf("uploaded file content = %q", got)
}
}
func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "hello\n",
"--content", "",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "empty markdown content is not supported") {
t.Fatalf("expected empty content validation error, got %v", err)
}
}
func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nstdout:\n%s", err, stdout.String())
}
return envelope.Data
}
type markdownPatchDryRunOutput struct {
Mode string `json:"mode"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
func newMarkdownPatchRuntime(t *testing.T, fileToken, pattern, content string, regex bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "markdown +patch"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("content", "", "")
cmd.Flags().Bool("regex", false, "")
for name, value := range map[string]string{
"file-token": fileToken,
"pattern": pattern,
"content": content,
} {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
if regex {
if err := cmd.Flags().Set("regex", "true"); err != nil {
t.Fatalf("set --regex: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, markdownTestConfig())
}
func decodeMarkdownPatchDryRun(t *testing.T, fileToken, pattern, content string, regex bool) markdownPatchDryRunOutput {
t.Helper()
runtime := newMarkdownPatchRuntime(t, fileToken, pattern, content, regex)
dry := MarkdownPatch.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run json: %v", err)
}
var out markdownPatchDryRunOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal dry-run json: %v\njson=%s", err, string(data))
}
return out
}

View File

@@ -182,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{"+create", "+fetch", "+overwrite"}
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
if len(got) != len(want) {
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))

View File

@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownFetch,
MarkdownPatch,
MarkdownOverwrite,
}
}

View File

@@ -19,7 +19,7 @@ metadata:
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`

View File

@@ -6,7 +6,7 @@
上传本地文件到飞书云空间。目标位置可以是 Drive 文件夹,也可以是 wiki 节点。
## 快速决策
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
## 命令

View File

@@ -1,6 +1,6 @@
---
name: lark-markdown
version: 1.0.0
version: 1.1.0
description: "飞书 Markdown查看、创建、上传和编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取或修改时使用。"
metadata:
requires:
@@ -16,6 +16,7 @@ metadata:
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
- 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch`
- 用户要对 Markdown 文件做**局部文本替换 / 正则替换**,优先使用 `lark-cli markdown +patch`
- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite`
- 用户要把本地 Markdown **导入成在线新版文档docx**,不要用本 skill改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill切到 [`lark-drive`](../lark-drive/SKILL.md)
@@ -28,6 +29,10 @@ metadata:
- 直接传字符串
- `@file` 从本地文件读取内容
- `-` 从 stdin 读取内容
- `markdown +patch` 的内部语义是:**先完整下载 Markdown再本地替换再整文件覆盖上传**
- `markdown +patch` 不是服务端原子 patch它是 CLI 侧编排出来的局部更新能力
- `markdown +patch` 当前只支持**单组** `--pattern` / `--content`
- `markdown +patch` 替换后的最终内容**不能为空**;如果替换后整篇 Markdown 变成空字符串CLI 会直接报错,不会上传空文件
- `--file` 只接受本地 `.md` 文件路径
## Shortcuts推荐优先使用
@@ -38,6 +43,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`
|----------|------|
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |
| [`+fetch`](references/lark-markdown-fetch.md) | Fetch a Markdown file from Drive |
| [`+patch`](references/lark-markdown-patch.md) | Patch a Markdown file in Drive via fetch-local-replace-overwrite |
| [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Markdown file in Drive |
## 参考

View File

@@ -0,0 +1,135 @@
# markdown +patch
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
对 Drive 中已有的原生 Markdown 文件做局部文本替换,并返回是否实际写入了新版本。
## 命令
```bash
# 字面量替换
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern 'hello markdown' \
--content 'hello patched'
# 正则替换RE2
lark-cli markdown +patch \
--file-token boxcnxxxx \
--regex \
--pattern 'hello (.+)' \
--content 'hi $1'
# 删除匹配内容
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern ' debug' \
--content ''
# --pattern / --content 也支持 @file
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern @./pattern.txt \
--content @./replacement.md
# 从 stdin 读取 replacement
printf 'hi patched\n' | \
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern 'hello markdown' \
--content -
# 预览底层编排
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern 'hello markdown' \
--content 'hello patched' \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | 目标 Markdown 文件 token |
| `--pattern` | 是 | 要匹配的文本;默认按字面量处理;支持直接传字符串、`@file``-`stdin |
| `--content` | 是 | 替换后的内容;支持直接传字符串、`@file``-`stdin允许空字符串 `''`,表示删除匹配内容 |
| `--regex` | 否 | 将 `--pattern` 按 Go RE2 正则解释;`--content` 支持 `$1` 这类分组替换;如果需要字面 `$`,请写成 `$$` |
## 关键约束
- 当前只支持**单组** `--pattern` / `--content`
- `--pattern` 必须显式传入且不能为空字符串
- `--content` 必须显式传入,但允许为空字符串
- 未加 `--regex` 时,行为等价于对整份 Markdown 文本执行 `strings.ReplaceAll`
- 加了 `--regex` 时,行为等价于对整份 Markdown 文本执行 RE2 全量替换;`--content` 里的 `$1``${name}` 会按 Go regexp replacement template 解释,字面 `$` 请写成 `$$`
- 替换后的最终 Markdown 不能为空;如果 patch 结果是空字符串CLI 会直接报错,不会上传空文件
- `0` 命中时命令仍然成功返回,但不会上传新版本
## 实现边界
- 该命令的内部语义是:**download -> local replace -> overwrite upload**
- 它不是服务端原子 patch如果有人在你下载后、上传前更新了同一文件本次 patch 仍可能覆盖那次中间修改
- 它不会返回详细匹配位置,只返回命中数量
- `--dry-run` 会同时展示两种可能的上传路径:`upload_all`(小文件)和 `upload_prepare/upload_part/upload_finish`(大文件分片上传)
## 返回值
命中并写入新版本:
```json
{
"ok": true,
"identity": "user",
"data": {
"updated": true,
"mode": "literal",
"match_count": 1,
"version": "7639217385152646325",
"size_bytes_before": 39,
"size_bytes_after": 41
}
}
```
未命中:
```json
{
"ok": true,
"identity": "user",
"data": {
"updated": false,
"mode": "literal",
"match_count": 0,
"version": "",
"size_bytes_before": 41,
"size_bytes_after": 41
}
}
```
其中:
- `updated` 表示本次是否真的上传了新版本
- `mode``literal``regex`
- `match_count` 是匹配次数
- `version` 只有在 `updated=true` 时才会有值
- `size_bytes_before` / `size_bytes_after` 分别是替换前后的 Markdown 大小
## 适用场景
- 只需要替换一小段 Markdown 文本,而不想自己手动 `fetch -> edit -> overwrite`
- 需要基于正则做简单批量替换
- 需要判断“这次是否真的改到了内容”
## 不适用场景
- 需要 rename / move / delete / permission / comment 管理:切到 [`lark-drive`](../../lark-drive/SKILL.md)
- 需要多组 patch 一次完成:当前不支持,改为多次调用 `markdown +patch`
- 需要真正原子更新:当前能力不提供
## 参考
- [lark-markdown](../SKILL.md) — Markdown 域总览
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -176,6 +176,34 @@ func TestMarkdownOverwriteDryRun_RejectsEmptyFile(t *testing.T) {
assert.Contains(t, errMsg, "empty markdown content is not supported")
}
func TestMarkdownPatchDryRun_Content(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+patch",
"--file-token", "boxcnMarkdownDryRun",
"--pattern", "TODO",
"--content", "DONE",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_prepare")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_part")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_finish")
}
func setMarkdownDryRunConfigEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -28,6 +28,7 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) {
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-markdown-" + suffix + ".md"
initialContent := "# Initial\n\nhello markdown workflow\n"
patchedContent := "# Initial\n\nhello patched workflow\n"
updatedContent := "# Updated\n\nnew body\n"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
@@ -73,6 +74,34 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) {
fetchInitialResult.AssertStdoutStatus(t, true)
require.Equal(t, initialContent, gjson.Get(fetchInitialResult.Stdout, "data.content").String(), "stdout:\n%s", fetchInitialResult.Stdout)
patchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+patch",
"--file-token", fileToken,
"--pattern", "hello markdown workflow",
"--content", "hello patched workflow",
},
DefaultAs: "user",
})
require.NoError(t, err)
patchResult.AssertExitCode(t, 0)
patchResult.AssertStdoutStatus(t, true)
require.Equal(t, true, gjson.Get(patchResult.Stdout, "data.updated").Bool(), "stdout:\n%s", patchResult.Stdout)
require.Equal(t, int64(1), gjson.Get(patchResult.Stdout, "data.match_count").Int(), "stdout:\n%s", patchResult.Stdout)
require.NotEmpty(t, gjson.Get(patchResult.Stdout, "data.version").String(), "stdout:\n%s", patchResult.Stdout)
fetchPatchedResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "user",
})
require.NoError(t, err)
fetchPatchedResult.AssertExitCode(t, 0)
fetchPatchedResult.AssertStdoutStatus(t, true)
require.Equal(t, patchedContent, gjson.Get(fetchPatchedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchPatchedResult.Stdout)
overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",