Compare commits

...

16 Commits

Author SHA1 Message Date
liangshuo-1
8c700aea00 chore(release): v1.0.36 (#1011)
Change-Id: Ifb0b6bf05d486943d9a689bf63dde2251dcd3500
2026-05-21 12:24:14 +08:00
MaxHuang22
42746d6c9d fix: revert incremental skills sync (#965) (#1008)
Change-Id: Ic95e8a74a0d6fc7f89782dccde867fd794cfcf46
2026-05-21 12:08:27 +08:00
zed
94b103dbf6 fix(auth): return validation error when --scope is empty in auth check (#999)
strings.Fields("") returns an empty slice, causing --scope "" to bypass
validation and return ok: true. Replace the false-positive success path
with an ErrValidation error so callers correctly detect the invalid input.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:52:05 +08:00
wangweiming-01
e19e09019c feat: return real tenant URLs for drive +upload and markdown +create (#992)
Change-Id: I6b513eef57a3479c8971b3bb6cbf005cad3f8040
2026-05-21 11:07:37 +08:00
search_zhuhao
3bab9a0692 docs(lark-drive): improve search evidence guidance (#864)
Change-Id: I000c2d56962e6da2a7ef77d986c2eb73ec286546
2026-05-20 20:45:41 +08:00
liangshuo-1
6840bb7415 chore(release): v1.0.35 (#995)
Change-Id: I6ddc8cfc029c684deb5de4f210357e19ade083e1
2026-05-20 19:46:10 +08:00
caojie0621
ce485eb3f5 fix(sheets): declare metadata scope for info shortcut (#994) 2026-05-20 19:43:21 +08:00
YangJunzhou-01
c98a49f2a3 docs(im): clarify media key formats for message media flags (#991)
* docs(im): clarify media path restrictions

* docs(im): clarify file key formats for message file flags

Change-Id: I329ca0db9e7a01b774846d522d1b2a64da74233c

---------

Co-authored-by: mtsui-cmyk <mervyntsui@gmail.com>
2026-05-20 17:39:14 +08:00
wangweiming-01
c02a38f077 feat: support wiki node target in markdown +create (#883)
Change-Id: Idb89464344599571cda3d27d136727553dcf0e7e
2026-05-20 17:03:32 +08:00
zhangheng023
3a3fc31d0b feat: add incremental skills sync (#965)
* feat: add incremental skills sync

* fix: address skills sync review feedback
2026-05-20 16:27:07 +08:00
wangweiming-01
8c73f49e91 docs: add media-preview reference (#990)
Change-Id: I5ba1991874e262fb98f3421e61503b58bb71d861
2026-05-20 15:59:39 +08:00
liujinkun2025
9272b9da99 docs(skills): migrate docs +search to drive +search and fix creator_ids owner semantic (#951)
docs +search is in maintenance and will be removed; cloud-space resource
discovery is consolidated onto drive +search. Two related doc/help fixes:

1. Redirect guidance: docs +search -> drive +search
   - skill-template/domains/{doc,sheets}.md
   - lark-base/SKILL.md: --filter '{"doc_types":["BITABLE"]}' -> --doc-types bitable
   - lark-sheets/SKILL.md: body + frontmatter description, add drive-search ref link
   Same server API, equivalent capability; only flattens the entry from
   nested --filter JSON to flags. reference links repointed to lark-drive.

2. Fix creator_ids/--mine semantic: creator -> owner
   The server matches creator_ids (incl. --mine / --creator-ids) by owner
   (document owner), not original creator, despite the OpenAPI field name.
   - shortcuts/drive/drive_search.go: --help Desc and Tip
   - lark-drive/references/lark-drive-search.md: identity section, params, rules, examples
   - lark-drive/SKILL.md: top-level guidance
   - lark-doc/references/lark-doc-search.md: creator_ids usage note (now self-consistent)
   Wire field name creator_ids kept (aligned with the server).

Docs/help strings only, no logic change; gofmt / go vet / package build pass.

Change-Id: If3ebf5a247b7e38b58050c677dc888a310f1c6b6
2026-05-20 15:08:50 +08:00
wangweiming-01
27a5eeddcc docs: prefer local comments for drive reviews (#981)
* docs: prefer local comments for drive reviews

Change-Id: Ie2eaa54320cd2612b66b2d617750d23b950e38db

* docs: align drive comment fallback guidance

Change-Id: Ia7512babe3656b57374c86068198c8192871ff81
2026-05-20 14:32:18 +08:00
zgz2048
0c4eadd41e docs: add wiki base fast path (#982) 2026-05-20 14:31:45 +08:00
yballul-bytedance
69c34481f5 feat: Product CLI 4no-meego (#759)
Change-Id: If08f236c8ae351f92683f2b861cc999eb6f1d22d
2026-05-20 14:02:03 +08:00
wangweiming-01
fa45e1c7e4 feat: add markdown +diff shortcut (#876)
* feat: add markdown +diff shortcut

Change-Id: I7da27889517707ac6f1d5e8c429e4bdfb49fdcf8

* fix: harden markdown diff downloads

Change-Id: I0020e14ebee780617d790836af1368db851b8cf1

* refactor: address markdown diff review feedback

Change-Id: I0ddb852218ec4784c0f9491896796c3007f04122
2026-05-20 12:20:51 +08:00
55 changed files with 4274 additions and 393 deletions

2
.gitignore vendored
View File

@@ -42,3 +42,5 @@ app.log
/server-demo
.tmp/
cover*.out
lark-env.sh

View File

@@ -2,6 +2,38 @@
All notable changes to this project will be documented in this file.
## [v1.0.36] - 2026-05-21
### Features
- **drive/markdown**: Return real tenant URLs for `drive +upload` and `markdown +create` (#992)
### Bug Fixes
- **auth**: Return validation error when `--scope` is empty in `auth check` (#999)
### Documentation
- **lark-drive**: Improve search evidence guidance (#864)
## [v1.0.35] - 2026-05-20
### Features
- **markdown**: Support wiki node target in `+create` (#883)
- **markdown**: Add `+diff` shortcut (#876)
- **base**: Add form `+detail` / `+submit` shortcuts (#759)
- **skills**: Add incremental skills sync (#965)
- **doc**: Warn before overwrite when document contains whiteboard or file blocks (#825)
### Documentation
- **im**: Clarify media key formats for message media flags (#991)
- **im**: Add media-preview reference (#990)
- **drive**: Migrate `docs +search` to `drive +search` and fix `creator_ids` owner semantic (#951)
- **drive**: Prefer local comments for drive reviews (#981)
- **wiki**: Add wiki base fast path (#982)
## [v1.0.34] - 2026-05-19
### Features
@@ -774,6 +806,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32

View File

@@ -47,8 +47,7 @@ func authCheckRun(opts *CheckOptions) error {
required := strings.Fields(opts.Scope)
if len(required) == 0 {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
return nil
return output.ErrValidation("--scope cannot be empty")
}
config, err := f.Config()

3
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2
@@ -19,6 +20,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/net v0.33.0
golang.org/x/sync v0.15.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.27.0
golang.org/x/text v0.23.0
@@ -61,5 +63,4 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
)

15
go.sum
View File

@@ -45,6 +45,7 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -73,6 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -97,6 +103,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
@@ -107,8 +115,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
@@ -163,7 +173,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.34",
"version": "1.0.36",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseFormDetail = common.Shortcut{
Service: "base",
Command: "+form-detail",
Description: "Get form detail by share token",
Risk: "read",
Scopes: []string{"base:form:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "share-token", Desc: "Form share token (share_token)", Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/tables/forms/detail").
Body(map[string]interface{}{
"share_token": runtime.Str("share-token"),
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := map[string]interface{}{
"share_token": runtime.Str("share-token"),
}
data, err := baseV3Call(runtime, "POST",
baseV3Path("bases", "tables", "forms", "detail"), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,334 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"sync"
"golang.org/x/sync/errgroup"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
uploadAttachConcurrency = 5
)
var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
Flags: []common.Flag{
{Name: "share-token", Desc: "Form share token (required), extracted from the form share link", Required: true},
{Name: "base-token", Desc: "Base token (required when --json contains attachments, used for uploading attachments to Base Drive Media)"},
{Name: "json", Desc: `JSON object containing "fields" (field values) and "attachments" (attachment file paths). Example: '{"fields":{"Rating":5,"Review":"Good"},"attachments":{"Attachment":["./a.pdf","./b.png"]}}'`, Required: true},
},
Tips: []string{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)
},
DryRun: dryRunFormSubmit,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFormSubmit(runtime)
},
}
func validateFormSubmit(runtime *common.RuntimeContext) error {
// 校验 --json 结构:提取 "fields" 和 "attachments"
pc := newParseCtx(runtime)
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return err
}
fields, _ := raw["fields"].(map[string]interface{})
attachments, hasAttachments := raw["attachments"]
if !hasAttachments && fields == nil {
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
}
if hasAttachments {
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
if runtime.Str("base-token") == "" {
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
}
attMap, ok := attachments.(map[string]interface{})
if !ok {
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
}
for fieldName, value := range attMap {
paths, ok := value.([]interface{})
if !ok {
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
}
for i, item := range paths {
if _, ok := item.(string); !ok {
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
}
}
if len(paths) == 0 {
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
}
}
}
return nil
}
// parseFormSubmitJSON 将 --json 解析为字段和附件映射。
func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}, map[string][]string, error) {
pc := newParseCtx(runtime)
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return nil, nil, err
}
fields, _ := raw["fields"].(map[string]interface{})
if fields == nil {
fields = make(map[string]interface{})
}
var attMap map[string][]string
if attachments, ok := raw["attachments"]; ok {
attObj, ok := attachments.(map[string]interface{})
if !ok {
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
}
if len(attObj) > 0 {
attMap = make(map[string][]string, len(attObj))
for fieldName, value := range attObj {
paths, ok := value.([]interface{})
if !ok {
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
}
filePaths := make([]string, 0, len(paths))
for _, item := range paths {
if s, ok := item.(string); ok {
filePaths = append(filePaths, s)
} else {
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
}
}
if len(filePaths) > 0 {
attMap[fieldName] = filePaths
}
}
}
}
return fields, attMap, nil
}
func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
if len(attachmentMap) > 0 {
dry := common.NewDryRunAPI().
Desc("Form submit with attachments: upload local files per field → merge with fields → submit")
for fieldName, filePaths := range attachmentMap {
for _, p := range filePaths {
fileName := filepath.Base(p)
dry = dry.POST("/open-apis/drive/v1/medias/upload_all").
Desc(fmt.Sprintf("Upload attachment for field %q: %s", fieldName, fileName)).
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseFormAttachmentParentType,
"parent_node": runtime.Str("base-token"),
"extra": baseFormAttachmentExtra(runtime.Str("share-token")),
"file": "@" + p,
"size": "<file_size>",
})
}
}
body := buildFormSubmitBody(runtime, fields)
dry = dry.POST("/open-apis/base/v3/bases/tables/forms/submit").
Body(body).
Desc("Submit form with uploaded attachment tokens merged with fields")
return dry
}
body := buildFormSubmitBody(runtime, fields)
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/tables/forms/submit").
Body(body)
}
func buildFormSubmitBody(runtime *common.RuntimeContext, content map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"share_token": runtime.Str("share-token"),
"content": content,
}
}
func executeFormSubmit(runtime *common.RuntimeContext) error {
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
if err != nil {
return err
}
// 上传附件并合并到字段中
if len(attachmentMap) > 0 {
baseToken := runtime.Str("base-token")
fio := runtime.FileIO()
if fio == nil {
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
}
// Step 1: 收集所有唯一路径(跨字段去重)
allPaths := collectUniquePaths(attachmentMap)
if len(allPaths) == 0 {
return common.FlagErrorf("attachments in --json contains no valid file paths")
}
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
sizeMap := make(map[string]int64, len(allPaths))
for _, filePath := range allPaths {
if _, err := validate.SafeInputPath(filePath); err != nil {
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
}
fileInfo, err := fio.Stat(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
}
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
}
if !fileInfo.Mode().IsRegular() {
return output.ErrValidation("attachment file %s is not a regular file", filePath)
}
sizeMap[filePath] = fileInfo.Size()
}
// Step 3: 并行上传,构建路径 → 附件结果映射
fmt.Fprintf(runtime.IO().ErrOut, "Uploading %d unique attachment(s)...\n", len(allPaths))
resultMap, err := uploadAttachmentsParallel(runtime, allPaths, baseFormAttachmentUploadTarget(baseToken, runtime.Str("share-token")), sizeMap)
if err != nil {
return err
}
// Step 4: 根据共享结果映射,按字段组装单元格
for fieldName, filePaths := range attachmentMap {
cell := make([]interface{}, 0, len(filePaths))
for _, p := range filePaths {
if att, ok := resultMap[p]; ok {
cell = append(cell, att)
}
}
fields[fieldName] = cell
}
fmt.Fprintf(runtime.IO().ErrOut, "Uploaded %d unique file(s) into %d field(s)\n", len(resultMap), len(attachmentMap))
}
body := buildFormSubmitBody(runtime, fields)
data, err := baseV3Call(runtime, "POST",
baseV3Path("bases", "tables", "forms", "submit"),
nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
// collectUniquePaths 收集所有字段中的文件路径,返回去重后的有序列表。
func collectUniquePaths(attachmentMap map[string][]string) []string {
seen := make(map[string]bool, len(attachmentMap)*4)
var order []string
for _, filePaths := range attachmentMap {
for _, p := range filePaths {
if !seen[p] {
seen[p] = true
order = append(order, p)
}
}
}
return order
}
func baseFormAttachmentUploadTarget(baseToken, shareToken string) baseAttachmentUploadTarget {
return baseAttachmentUploadTarget{
ParentType: baseFormAttachmentParentType,
ParentNode: baseToken,
Extra: baseFormAttachmentExtra(shareToken),
}
}
func baseFormAttachmentExtra(shareToken string) string {
extra, err := json.Marshal(map[string]string{"share_token": shareToken})
if err != nil {
return ""
}
return string(extra)
}
// uploadAttachmentsParallel 并发上传文件,返回路径 → 附件对象的映射。
func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, target baseAttachmentUploadTarget, sizeMap map[string]int64) (map[string]interface{}, error) {
var (
mu sync.Mutex
resultMap = make(map[string]interface{}, len(paths))
)
g, _ := errgroup.WithContext(runtime.Ctx())
g.SetLimit(uploadAttachConcurrency) // 限制并发数
for _, filePath := range paths {
fp := filePath // 捕获循环变量
g.Go(func() error {
fileName := filepath.Base(fp)
fmt.Fprintf(runtime.IO().ErrOut, " Uploading: %s\n", fileName)
att, err := uploadSingleAttachment(runtime, fp, fileName, sizeMap[fp], target)
if err != nil {
return err
}
mu.Lock()
resultMap[fp] = att
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return resultMap, nil
}
// uploadSingleAttachment 上传单个文件,返回附件单元格项。
// 前置条件:文件已通过校验(存在、常规文件、大小在限制内)。
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
if err != nil {
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
}
return att, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,10 +31,17 @@ import (
const (
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
baseAttachmentParentType = "bitable_file"
baseFormAttachmentParentType = "bitable_tmp_point"
baseAttachmentMaxBatchSize = 50
baseAttachmentGetMaxRecords = 10
)
type baseAttachmentUploadTarget struct {
ParentType string
ParentNode string
Extra string
}
var BaseRecordUploadAttachment = common.Shortcut{
Service: "base",
Command: "+record-upload-attachment",
@@ -278,7 +285,10 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, fileInfo.Size(), baseAttachmentUploadTarget{
ParentType: baseAttachmentParentType,
ParentNode: runtime.Str("base-token"),
})
if err != nil {
return err
}
@@ -459,31 +469,33 @@ func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValu
return attachments, nil
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (map[string]interface{}, error) {
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
if err != nil {
return nil, err
}
parentNode := baseToken
var (
fileToken string
)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
parentNode := target.ParentNode
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: baseAttachmentParentType,
ParentType: target.ParentType,
ParentNode: &parentNode,
Extra: target.Extra,
})
} else {
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: baseAttachmentParentType,
ParentNode: parentNode,
ParentType: target.ParentType,
ParentNode: target.ParentNode,
Extra: target.Extra,
})
}
if err != nil {

View File

@@ -70,10 +70,12 @@ func Shortcuts() []common.Shortcut {
BaseFormsList,
BaseFormUpdate,
BaseFormGet,
BaseFormDetail,
BaseFormQuestionsCreate,
BaseFormQuestionsDelete,
BaseFormQuestionsUpdate,
BaseFormQuestionsList,
BaseFormSubmit,
BaseDashboardList,
BaseDashboardGet,
BaseDashboardCreate,

View File

@@ -3,29 +3,61 @@
package common
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
// DriveMeta is the subset of drive metas/batch_query fields used by shortcuts.
type DriveMeta struct {
Title string
URL string
}
// FetchDriveMeta looks up document metadata via the drive metas batch_query API.
func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool) (DriveMeta, error) {
body := map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": token,
"doc_type": docType,
},
},
}
if withURL {
body["with_url"] = true
}
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": token,
"doc_type": docType,
},
},
},
body,
)
if err != nil {
return "", err
return DriveMeta{}, err
}
metas := GetSlice(data, "metas")
if len(metas) == 0 {
return "", nil
return DriveMeta{}, nil
}
meta, _ := metas[0].(map[string]interface{})
return GetString(meta, "title"), nil
return DriveMeta{
Title: GetString(meta, "title"),
URL: GetString(meta, "url"),
}, nil
}
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
meta, err := FetchDriveMeta(runtime, token, docType, false)
if err != nil {
return "", err
}
return meta.Title, nil
}
// FetchDriveMetaURL looks up the document access URL via the drive metas batch_query API.
func FetchDriveMetaURL(runtime *RuntimeContext, token, docType string) (string, error) {
meta, err := FetchDriveMeta(runtime, token, docType, true)
if err != nil {
return "", err
}
return meta.URL, nil
}

View File

@@ -5,6 +5,7 @@ package common
import (
"context"
"encoding/json"
"fmt"
"sync/atomic"
"testing"
@@ -105,6 +106,44 @@ func TestFetchDriveMetaTitle(t *testing.T) {
})
}
func TestFetchDriveMetaURL(t *testing.T) {
runtime, reg := newDriveMetaTestRuntime(t)
stub := &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{}{
{
"doc_token": "boxcnABC",
"doc_type": "file",
"title": "report.pdf",
"url": "https://tenant.example.com/file/boxcnABC",
},
},
},
},
}
reg.Register(stub)
got, err := FetchDriveMetaURL(runtime, "boxcnABC", "file")
if err != nil {
t.Fatalf("FetchDriveMetaURL() error: %v", err)
}
if got != "https://tenant.example.com/file/boxcnABC" {
t.Fatalf("url = %q, want tenant URL", got)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode captured body: %v", err)
}
if body["with_url"] != true {
t.Fatalf("with_url = %#v, want true", body["with_url"])
}
}
func newDriveMetaTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -31,6 +31,7 @@ var DriveImport = common.Shortcut{
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveImportSpec(driveImportSpec{
@@ -38,6 +39,7 @@ var DriveImport = common.Shortcut{
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -46,11 +48,15 @@ var DriveImport = common.Shortcut{
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
@@ -76,6 +82,7 @@ var DriveImport = common.Shortcut{
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err

View File

@@ -51,6 +51,7 @@ type driveImportSpec struct {
DocType string
FolderToken string
Name string
TargetToken string // existing bitable token to import data into (only for type=bitable)
}
func (s driveImportSpec) FileExtension() string {
@@ -67,7 +68,7 @@ func (s driveImportSpec) TargetFileName() string {
// CreateTaskBody builds the request body expected by /drive/v1/import_tasks.
func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} {
return map[string]interface{}{
body := map[string]interface{}{
"file_extension": s.FileExtension(),
"file_token": fileToken,
"type": s.DocType,
@@ -79,6 +80,12 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
"mount_key": s.FolderToken,
},
}
if s.DocType == "bitable" && s.TargetToken != "" {
body["token"] = s.TargetToken
}
return body
}
// uploadMediaForImport uploads the source file to the temporary import media
@@ -232,6 +239,15 @@ func validateDriveImportSpec(spec driveImportSpec) error {
}
}
if strings.TrimSpace(spec.TargetToken) != "" {
if spec.DocType != "bitable" {
return output.ErrValidation("--target-token is only supported when --type is bitable")
}
if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
return nil
}

View File

@@ -45,6 +45,19 @@ func TestValidateDriveImportSpec(t *testing.T) {
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
wantErr: "unsupported file extension",
},
{
name: "target-token rejected for non-bitable type",
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "sheet", TargetToken: "bascnxxx"},
wantErr: "--target-token is only supported when --type is bitable",
},
{
name: "target-token accepted for bitable",
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable", TargetToken: "bascnxxx"},
},
{
name: "target-token empty for bitable still ok",
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable"},
},
}
for _, tt := range tests {

View File

@@ -84,6 +84,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -148,6 +149,7 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -197,6 +199,7 @@ func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "../outside.md"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -250,6 +253,7 @@ func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./large.md"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -296,6 +300,7 @@ func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./folder-input"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -366,6 +371,165 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
}
}
func TestDriveImportCreateTaskBodyWithTargetToken(t *testing.T) {
t.Parallel()
spec := driveImportSpec{
FilePath: "/tmp/data.xlsx",
DocType: "bitable",
TargetToken: "bascnxxxxx",
}
body := spec.CreateTaskBody("file_token_test")
// point stays the same as default (mount_type=1)
point, ok := body["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", body["point"])
}
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
t.Fatalf("mount_type = %v (%T), want 1", mt, mt)
}
// token is injected at body top-level
if tt, _ := body["token"].(string); tt != "bascnxxxxx" {
t.Fatalf("token = %q, want %q", tt, "bascnxxxxx")
}
}
func TestDriveImportCreateTaskBodyTargetTokenIgnoredForNonBitable(t *testing.T) {
t.Parallel()
spec := driveImportSpec{
FilePath: "/tmp/data.xlsx",
DocType: "sheet",
TargetToken: "bascnxxxxx",
FolderToken: "fld_test",
}
body := spec.CreateTaskBody("file_token_test")
point, ok := body["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", body["point"])
}
// Non-bitable should use default folder mount (type=1), ignoring TargetToken
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
t.Fatalf("mount_type = %v (%T), want 1 (folder mount)", mt, mt)
}
if _, exists := point["target_token"]; exists {
t.Fatal("target_token should not be present for non-bitable type")
}
}
func TestDriveImportDryRunWithTargetToken(t *testing.T) {
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "bitable"); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("target-token", "bascntarget123"); err != nil {
t.Fatalf("set --target-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.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: %v", err)
}
var got struct {
API []struct {
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
// The import task body (API[1]) should contain target_token in point
importTaskBody := got.API[1].Body
point, ok := importTaskBody["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", importTaskBody["point"])
}
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
t.Fatalf("dry-run mount_type = %v (%T), want 1 (unchanged)", mt, mt)
}
if tt, _ := importTaskBody["token"].(string); tt != "bascntarget123" {
t.Fatalf("dry-run token = %q, want %q", tt, "bascntarget123")
}
}
func TestDriveImportDryRunTargetTokenRejectedForSheet(t *testing.T) {
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "sheet"); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("target-token", "bascnxxx"); err != nil {
t.Fatalf("set --target-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.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: %v", err)
}
var got struct {
Error string `json:"error"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Error == "" || !strings.Contains(got.Error, "--target-token is only supported when --type is bitable") {
t.Fatalf("dry-run error = %q, want target-token validation error", got.Error)
}
}
// driveImportMockEnv mounts the three stubs needed for a full +import run:
// media upload_all -> import_tasks (create) -> import_tasks/<ticket> (poll).
// Returns nothing; caller asserts on stdout via decodeDriveEnvelope.

View File

@@ -604,9 +604,9 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) {
}
}
func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
func TestDriveUploadUsesMetaURLForExplorerParent(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
AppID: "drive-upload-explorer-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
@@ -615,12 +615,21 @@ func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
// upload_all only ever returns file_token; url is never present —
// this exercises the fallback path unconditionally for explorer
// parents.
"data": map[string]interface{}{"file_token": "file_explorer_small"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "file_explorer_small", "doc_type": "file", "url": "https://tenant.example.com/file/file_explorer_small"},
},
},
},
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
@@ -641,14 +650,14 @@ func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/file/file_explorer_small"; got != want {
t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
if got, want := data["url"], "https://tenant.example.com/file/file_explorer_small"; got != want {
t.Fatalf("data.url = %#v, want %q (metadata URL)", got, want)
}
}
func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
func TestDriveUploadUsesMetaURLForWikiParent(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
AppID: "drive-upload-wiki-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
@@ -660,6 +669,18 @@ func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
"data": map[string]interface{}{"file_token": "file_wiki_small"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "file_wiki_small", "doc_type": "file", "url": "https://tenant.example.com/file/file_wiki_small"},
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
@@ -677,8 +698,8 @@ func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["url"]; ok {
t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"])
if got, want := data["url"], "https://tenant.example.com/file/file_wiki_small"; got != want {
t.Fatalf("data.url = %#v, want %q (metadata URL)", got, want)
}
}
@@ -1078,14 +1099,15 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
var got struct {
API []struct {
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[0].Body["parent_type"] != driveUploadParentTypeWiki {
t.Fatalf("parent_type = %#v, want %q", got.API[0].Body["parent_type"], driveUploadParentTypeWiki)
@@ -1093,6 +1115,12 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
if got.API[0].Body["parent_node"] != "wikcn_dryrun_upload_target" {
t.Fatalf("parent_node = %#v, want %q", got.API[0].Body["parent_node"], "wikcn_dryrun_upload_target")
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
}
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
}
}
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
@@ -1168,18 +1196,25 @@ func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
var got struct {
API []struct {
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
}
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
}
}
func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
@@ -1222,8 +1257,8 @@ func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")

View File

@@ -77,8 +77,8 @@ var DriveSearch = common.Shortcut{
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I created (uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated creator open_ids; mutually exclusive with --mine"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
{Name: "edited-until", Desc: "end of [my edited] time window"},
@@ -108,7 +108,7 @@ var DriveSearch = common.Shortcut{
Tips: []string{
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
"Use --mine for a quick \"docs I created\" filter. For other people, use --creator-ids ou_xxx,ou_yyy.",
"Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.",
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -92,7 +92,7 @@ var DriveUpload = common.Shortcut{
Command: "+upload",
Description: "Upload a local file to Drive",
Risk: "write",
Scopes: []string{"drive:file:upload"},
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
@@ -124,11 +124,22 @@ var DriveUpload = common.Shortcut{
body["file_token"] = spec.FileToken
}
d := common.NewDryRunAPI().
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload), then fetch the real Drive URL via metadata").
POST("/open-apis/drive/v1/files/upload_all").
Body(body)
d.POST("/open-apis/drive/v1/metas/batch_query").
Desc("Fetch the uploaded file's real access URL").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": "<file_token from upload response>",
"doc_type": "file",
},
},
"with_url": true,
})
if runtime.IsBot() && !isOverwrite {
d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
}
return d
},
@@ -165,13 +176,10 @@ var DriveUpload = common.Shortcut{
if uploadResult.Version != "" {
out["version"] = uploadResult.Version
}
// wiki-hosted files have no standalone /file/<token> URL — only the
// wiki node URL, which the upload response doesn't carry. Skip the
// fallback for parent_type=wiki rather than emit a link that 404s.
if target.ParentType == driveUploadParentTypeExplorer {
if u := common.BuildResourceURL(runtime.Config.Brand, "file", uploadResult.FileToken); u != "" {
out["url"] = u
}
if u, metaErr := common.FetchDriveMetaURL(runtime, uploadResult.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
out["url"] = u
} else if metaErr != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: uploaded file URL lookup failed: %v\n", metaErr)
}
if !isOverwrite {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil {

View File

@@ -29,11 +29,11 @@ var ImMessagesReply = common.Shortcut{
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
{Name: "image", Desc: "image_key, local file path"},
{Name: "file", Desc: "file_key, local file path"},
{Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"},
{Name: "audio", Desc: "audio file_key, local file path"},
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
},

View File

@@ -33,11 +33,11 @@ var ImMessagesSend = common.Shortcut{
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
{Name: "image", Desc: "image_key, local file path"},
{Name: "file", Desc: "file_key, local file path"},
{Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"},
{Name: "audio", Desc: "audio file_key, local file path"},
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatFlag := runtime.Str("chat-id")

View File

@@ -6,9 +6,11 @@ package im
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
)
func TestValidateMediaFlagPath(t *testing.T) {
@@ -49,3 +51,37 @@ func TestValidateMediaFlagPath(t *testing.T) {
})
}
}
func TestIMMediaFlagDescriptionsDocumentPathRestrictions(t *testing.T) {
shortcuts := []struct {
name string
flags []common.Flag
}{
{name: "messages-send", flags: ImMessagesSend.Flags},
{name: "messages-reply", flags: ImMessagesReply.Flags},
}
mediaFlags := []string{"image", "file", "video", "video-cover", "audio"}
for _, sc := range shortcuts {
for _, flagName := range mediaFlags {
t.Run(sc.name+"/"+flagName, func(t *testing.T) {
desc := findFlagDesc(t, sc.flags, flagName)
for _, want := range []string{"URL", "cwd-relative local path", "absolute paths", ".. are rejected"} {
if !strings.Contains(desc, want) {
t.Fatalf("%s --%s description = %q, want it to mention %q", sc.name, flagName, desc, want)
}
}
})
}
}
}
func findFlagDesc(t *testing.T, flags []common.Flag, name string) string {
t.Helper()
for _, flag := range flags {
if flag.Name == name {
return flag.Desc
}
}
t.Fatalf("flag %q not found", name)
return ""
}

View File

@@ -24,10 +24,16 @@ import (
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
const (
markdownUploadParentTypeExplorer = "explorer"
markdownUploadParentTypeWiki = "wiki"
)
type markdownUploadSpec struct {
FileToken string
FileName string
FolderToken string
WikiToken string
FilePath string
Content string
ContentSet bool
@@ -45,6 +51,25 @@ type markdownMultipartSession struct {
BlockNum int
}
type markdownUploadTarget struct {
ParentType string
ParentNode string
}
func (spec markdownUploadSpec) Target() markdownUploadTarget {
if spec.WikiToken != "" {
return markdownUploadTarget{
ParentType: markdownUploadParentTypeWiki,
ParentNode: spec.WikiToken,
}
}
// An empty explorer parent node uploads to the user's Drive root folder.
return markdownUploadTarget{
ParentType: markdownUploadParentTypeExplorer,
ParentNode: spec.FolderToken,
}
}
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
switch {
case spec.ContentSet && spec.FileSet:
@@ -53,14 +78,32 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
return common.FlagErrorf("specify exactly one of --content or --file")
}
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
}
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
}
targets := 0
if spec.FolderToken != "" {
targets++
}
if spec.WikiToken != "" {
targets++
}
if targets > 1 {
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
if spec.WikiToken != "" {
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
if requireName && spec.ContentSet {
if strings.TrimSpace(spec.FileName) == "" {
@@ -92,6 +135,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
return nil
}
func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string) bool {
return runtime.Changed(flagName) && strings.TrimSpace(runtime.Str(flagName)) == ""
}
func validateMarkdownFileName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
@@ -137,11 +184,19 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
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 nil, wrapMarkdownDownloadError(err)
}
return resp, nil
}
func wrapMarkdownDownloadError(err error) error {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("download failed: %s", err)
}
func validateNonEmptyMarkdownSize(size int64) error {
if size == 0 {
return output.ErrValidation("%s", markdownEmptyContentError)
@@ -170,6 +225,24 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
return size, nil
}
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
}
if strings.TrimSpace(version) != "" {
req.QueryParams = larkcore.QueryParams{
"version": []string{strings.TrimSpace(version)},
}
}
resp, err := runtime.DoAPIStream(ctx, req)
if err != nil {
return nil, "", wrapMarkdownDownloadError(err)
}
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
}
func markdownDryRunFileField(spec markdownUploadSpec) string {
if spec.FilePath != "" {
return "@" + spec.FilePath
@@ -179,12 +252,13 @@ func markdownDryRunFileField(spec markdownUploadSpec) string {
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
fileName := finalMarkdownFileName(spec)
target := spec.Target()
if !multipart {
body := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
"file": markdownDryRunFileField(spec),
}
@@ -205,8 +279,8 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
prepareBody := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
}
if spec.FileToken != "" {
@@ -241,6 +315,7 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
fileName := strings.TrimSpace(spec.FileName)
target := spec.Target()
if fileName == "" && spec.FileSet {
fileName = finalMarkdownFileName(spec)
}
@@ -267,8 +342,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
Desc("[2] Overwrite file contents with multipart/form-data upload").
Body(map[string]interface{}{
"file_name": spec.FileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
"file": markdownDryRunFileField(spec),
"file_token": spec.FileToken,
@@ -280,8 +355,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
Desc("[2] Initialize multipart overwrite upload").
Body(map[string]interface{}{
"file_name": spec.FileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
"file_token": spec.FileToken,
}).
@@ -326,10 +401,11 @@ func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUpload
}
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
target := spec.Target()
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", "explorer")
fd.AddField("parent_node", spec.FolderToken)
fd.AddField("parent_type", target.ParentType)
fd.AddField("parent_node", target.ParentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if spec.FileToken != "" {
fd.AddField("file_token", spec.FileToken)
@@ -357,10 +433,11 @@ func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSp
}
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
target := spec.Target()
prepareBody := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
}
if spec.FileToken != "" {

View File

@@ -5,6 +5,7 @@ package markdown
import (
"context"
"fmt"
"io"
"strings"
@@ -16,19 +17,25 @@ var MarkdownCreate = common.Shortcut{
Command: "+create",
Description: "Create a Markdown file in Drive",
Risk: "write",
Scopes: []string{"drive:file:upload"},
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
{Name: "folder-token", Desc: "target Drive folder token (default: root folder; mutually exclusive with --wiki-token)"},
{Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"},
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "file", Desc: "local .md file path"},
},
Tips: []string{
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
@@ -39,6 +46,7 @@ var MarkdownCreate = common.Shortcut{
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
@@ -48,12 +56,25 @@ var MarkdownCreate = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
dry := markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
dry.POST("/open-apis/drive/v1/metas/batch_query").
Desc("Fetch the created Markdown file's real access URL").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": "<file_token from upload response>",
"doc_type": "file",
},
},
"with_url": true,
})
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
@@ -79,8 +100,10 @@ var MarkdownCreate = common.Shortcut{
"file_name": finalMarkdownFileName(spec),
"size_bytes": fileSize,
}
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
if u, metaErr := common.FetchDriveMetaURL(runtime, result.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
out["url"] = u
} else if metaErr != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: created Markdown file URL lookup failed: %v\n", metaErr)
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
out["permission_grant"] = grant

View File

@@ -0,0 +1,540 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
markdownDiffModeRemoteVsRemote = "remote_vs_remote"
markdownDiffModeRemoteVsLocal = "remote_vs_local"
markdownDiffMaxContentBytes = 10 * 1024 * 1024
markdownDiffTimeout = 30 * time.Second
)
var markdownDiffVersionRe = regexp.MustCompile(`^\d{1,19}$`)
type markdownDiffSpec struct {
FileToken string
FromVersion string
ToVersion string
FilePath string
ContextLines int
Format string
}
type markdownDiffHunk struct {
Header string `json:"header"`
OldStart int `json:"old_start"`
OldLines int `json:"old_lines"`
NewStart int `json:"new_start"`
NewLines int `json:"new_lines"`
}
type markdownDiffLineKind int
const (
markdownDiffLineEqual markdownDiffLineKind = iota
markdownDiffLineDelete
markdownDiffLineInsert
)
type markdownDiffLineOp struct {
Kind markdownDiffLineKind
Content string
}
type markdownDiffHunkRange struct {
Start int
End int
}
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.FromVersion != "" {
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
return err
}
}
if spec.ToVersion != "" {
if err := validateMarkdownDiffVersionValue(spec.ToVersion, "--to-version"); err != nil {
return err
}
}
if spec.FilePath != "" {
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
return output.ErrValidation("unsafe file path: %s", err)
}
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
return err
}
}
if spec.ContextLines < 0 {
return output.ErrValidation("--context-lines must be >= 0")
}
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
return output.ErrValidation("markdown +diff only supports --format json or pretty")
}
if spec.FilePath == "" {
if spec.FromVersion == "" && spec.ToVersion == "" {
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
}
if spec.FromVersion == "" && spec.ToVersion != "" {
return common.FlagErrorf("--to-version requires --from-version")
}
return nil
}
if spec.ToVersion != "" {
return common.FlagErrorf("--to-version is not supported together with --file")
}
return nil
}
func validateMarkdownDiffVersionValue(value, flagName string) error {
value = strings.TrimSpace(value)
if value == "" {
return output.ErrValidation("%s cannot be empty", flagName)
}
if !markdownDiffVersionRe.MatchString(value) {
return output.ErrValidation("%s must be a numeric version string", flagName)
}
return nil
}
func markdownDiffMode(spec markdownDiffSpec) string {
if spec.FilePath != "" {
return markdownDiffModeRemoteVsLocal
}
return markdownDiffModeRemoteVsRemote
}
func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI().Desc("Download the requested Markdown content, compute a unified diff locally, and print the result without modifying the remote file")
switch markdownDiffMode(spec) {
case markdownDiffModeRemoteVsLocal:
if spec.FromVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the specified remote Markdown version").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
}
dry.Set("local_file", spec.FilePath)
dry.Set("mode", markdownDiffModeRemoteVsLocal)
default:
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the base remote Markdown version").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
if spec.ToVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the target remote Markdown version").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.ToVersion})
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
}
dry.Set("mode", markdownDiffModeRemoteVsRemote)
}
dry.Set("context_lines", spec.ContextLines)
return dry
}
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
payload, err := readMarkdownDiffPayload(resp.Body, "remote Markdown content")
if err != nil {
return "", "", wrapMarkdownDownloadError(err)
}
return fileName, string(payload), nil
}
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer f.Close()
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", err
}
return "", output.ErrValidation("cannot read file: %s", err)
}
return string(payload), nil
}
func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
payload, err := io.ReadAll(io.LimitReader(r, markdownDiffMaxContentBytes+1))
if err != nil {
return nil, err
}
if len(payload) > markdownDiffMaxContentBytes {
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
}
return payload, nil
}
func splitMarkdownDiffLines(text string) []string {
if text == "" {
return nil
}
lines := strings.SplitAfter(text, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func markdownDiffLineOps(fromContent, toContent string) []markdownDiffLineOp {
dmp := diffmatchpatch.New()
dmp.DiffTimeout = markdownDiffTimeout
before, after, lineArray := dmp.DiffLinesToRunes(fromContent, toContent)
diffs := dmp.DiffMainRunes(before, after, false)
// Keep the diff line-based. Running cleanup after hydrating real text
// would re-split replacements into word-level edits.
diffs = dmp.DiffCharsToLines(diffs, lineArray)
ops := make([]markdownDiffLineOp, 0, len(diffs))
for _, diff := range diffs {
lines := splitMarkdownDiffLines(diff.Text)
for _, line := range lines {
switch diff.Type {
case diffmatchpatch.DiffDelete:
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineDelete, Content: line})
case diffmatchpatch.DiffInsert:
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineInsert, Content: line})
default:
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineEqual, Content: line})
}
}
}
return ops
}
func markdownDiffSummary(ops []markdownDiffLineOp) (bool, int, int) {
added := 0
deleted := 0
changed := false
for _, op := range ops {
switch op.Kind {
case markdownDiffLineDelete:
changed = true
deleted++
case markdownDiffLineInsert:
changed = true
added++
}
}
return changed, added, deleted
}
func markdownDiffHunkRanges(ops []markdownDiffLineOp, contextLines int) []markdownDiffHunkRange {
if len(ops) == 0 {
return nil
}
changedLines := make([]int, 0)
for i, op := range ops {
if op.Kind != markdownDiffLineEqual {
changedLines = append(changedLines, i)
}
}
if len(changedLines) == 0 {
return nil
}
ranges := make([]markdownDiffHunkRange, 0, len(changedLines))
current := markdownDiffHunkRange{
Start: max(0, changedLines[0]-contextLines),
End: min(len(ops), changedLines[0]+contextLines+1),
}
for _, idx := range changedLines[1:] {
next := markdownDiffHunkRange{
Start: max(0, idx-contextLines),
End: min(len(ops), idx+contextLines+1),
}
if next.Start <= current.End {
if next.End > current.End {
current.End = next.End
}
continue
}
ranges = append(ranges, current)
current = next
}
ranges = append(ranges, current)
return ranges
}
func markdownDiffHunkAt(ops []markdownDiffLineOp, r markdownDiffHunkRange) markdownDiffHunk {
oldBefore := 0
newBefore := 0
for _, op := range ops[:r.Start] {
if op.Kind != markdownDiffLineInsert {
oldBefore++
}
if op.Kind != markdownDiffLineDelete {
newBefore++
}
}
oldLines := 0
newLines := 0
for _, op := range ops[r.Start:r.End] {
if op.Kind != markdownDiffLineInsert {
oldLines++
}
if op.Kind != markdownDiffLineDelete {
newLines++
}
}
oldStart := oldBefore + 1
newStart := newBefore + 1
if oldLines == 0 {
oldStart = oldBefore
}
if newLines == 0 {
newStart = newBefore
}
return markdownDiffHunk{
Header: fmt.Sprintf("@@ -%d,%d +%d,%d @@", oldStart, oldLines, newStart, newLines),
OldStart: oldStart,
OldLines: oldLines,
NewStart: newStart,
NewLines: newLines,
}
}
func buildMarkdownUnifiedDiff(fromLabel, toLabel string, ops []markdownDiffLineOp, ranges []markdownDiffHunkRange) string {
if len(ranges) == 0 {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "--- %s\n", fromLabel)
fmt.Fprintf(&b, "+++ %s\n", toLabel)
for _, r := range ranges {
hunk := markdownDiffHunkAt(ops, r)
b.WriteString(hunk.Header)
b.WriteByte('\n')
for _, op := range ops[r.Start:r.End] {
prefix := ' '
switch op.Kind {
case markdownDiffLineDelete:
prefix = '-'
case markdownDiffLineInsert:
prefix = '+'
}
b.WriteByte(byte(prefix))
b.WriteString(op.Content)
if !strings.HasSuffix(op.Content, "\n") {
b.WriteByte('\n')
b.WriteString(`\ No newline at end of file`)
b.WriteByte('\n')
}
}
}
return b.String()
}
func summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent string, contextLines int) (string, bool, int, int, []markdownDiffHunk) {
ops := markdownDiffLineOps(fromContent, toContent)
changed, added, deleted := markdownDiffSummary(ops)
ranges := markdownDiffHunkRanges(ops, contextLines)
hunks := make([]markdownDiffHunk, 0, len(ranges))
for _, r := range ranges {
hunks = append(hunks, markdownDiffHunkAt(ops, r))
}
return buildMarkdownUnifiedDiff(fromLabel, toLabel, ops, ranges), changed, added, deleted, hunks
}
func colorizeUnifiedDiff(diffText string) string {
if diffText == "" {
return ""
}
lines := strings.SplitAfter(diffText, "\n")
var b strings.Builder
for _, line := range lines {
trimmed := strings.TrimRight(line, "\n")
suffix := ""
if strings.HasSuffix(line, "\n") {
suffix = "\n"
}
switch {
case strings.HasPrefix(trimmed, "@@"):
b.WriteString(output.Cyan)
b.WriteString(trimmed)
b.WriteString(output.Reset)
case strings.HasPrefix(trimmed, "+++"), strings.HasPrefix(trimmed, "---"):
b.WriteString(output.Bold)
b.WriteString(trimmed)
b.WriteString(output.Reset)
case strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "+++"):
b.WriteString(output.Green)
b.WriteString(trimmed)
b.WriteString(output.Reset)
case strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "---"):
b.WriteString(output.Red)
b.WriteString(trimmed)
b.WriteString(output.Reset)
default:
b.WriteString(trimmed)
}
b.WriteString(suffix)
}
return b.String()
}
func prettyPrintMarkdownDiff(w io.Writer, data map[string]interface{}) {
if !common.GetBool(data, "changed") {
io.WriteString(w, "No differences.\n")
return
}
io.WriteString(w, colorizeUnifiedDiff(common.GetString(data, "diff")))
}
var MarkdownDiff = common.Shortcut{
Service: "markdown",
Command: "+diff",
Description: "Compare remote Markdown versions or compare remote Markdown against a local file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target Markdown file token", Required: true},
{Name: "from-version", Desc: "base remote version; when --to-version is omitted, compare this version to the latest remote version"},
{Name: "to-version", Desc: "target remote version; requires --from-version"},
{Name: "file", Desc: "local .md file path to compare against the remote content"},
{Name: "context-lines", Desc: "number of unchanged context lines to include around each diff hunk", Type: "int", Default: "3"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
Format: runtime.Format,
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return markdownDiffDryRun(markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
}
var (
fromLabel string
toLabel string
fromContent string
toContent string
err error
)
switch markdownDiffMode(spec) {
case markdownDiffModeRemoteVsLocal:
fromLabel = "a/" + spec.FileToken
if spec.FromVersion != "" {
fromLabel += "@version:" + spec.FromVersion
} else {
fromLabel += "@latest"
}
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
if err != nil {
return err
}
toLabel = "b/" + spec.FilePath
toContent, err = readMarkdownLocalFile(runtime, spec.FilePath)
if err != nil {
return err
}
default:
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
if err != nil {
return err
}
if spec.ToVersion != "" {
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
} else {
toLabel = "b/" + spec.FileToken + "@latest"
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
}
if err != nil {
return err
}
}
diffText, changed, addedLines, deletedLines, hunks := summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent, spec.ContextLines)
out := map[string]interface{}{
"changed": changed,
"mode": markdownDiffMode(spec),
"file_token": spec.FileToken,
"from_version": spec.FromVersion,
"to_version": spec.ToVersion,
"from_label": fromLabel,
"to_label": toLabel,
"added_lines": addedLines,
"deleted_lines": deletedLines,
"context_lines": spec.ContextLines,
"hunks": hunks,
"diff": diffText,
}
if spec.FilePath != "" {
out["local_file"] = spec.FilePath
}
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
prettyPrintMarkdownDiff(w, out)
})
return nil
},
}

View File

@@ -0,0 +1,379 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestMarkdownDiffRejectsUnsupportedFormat(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--format", "table",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only supports --format json or pretty") {
t.Fatalf("expected format validation error, got %v", err)
}
}
func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--to-version", "7633658129540910628",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--to-version requires --from-version") {
t.Fatalf("expected version validation error, got %v", err)
}
}
func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--to-version", "7633658129540910628",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Changed bool `json:"changed"`
Mode string `json:"mode"`
FromVersion string `json:"from_version"`
ToVersion string `json:"to_version"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Diff string `json:"diff"`
Hunks []markdownDiffHunk `json:"hunks"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
}
if !env.OK {
t.Fatalf("expected ok=true, got false: %s", stdout.String())
}
if !env.Data.Changed {
t.Fatalf("expected changed=true: %s", stdout.String())
}
if env.Data.Mode != markdownDiffModeRemoteVsRemote {
t.Fatalf("mode = %q, want %q", env.Data.Mode, markdownDiffModeRemoteVsRemote)
}
if env.Data.FromVersion != "7633658129540910621" || env.Data.ToVersion != "7633658129540910628" {
t.Fatalf("versions = %q -> %q", env.Data.FromVersion, env.Data.ToVersion)
}
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 1 {
t.Fatalf("added/deleted = %d/%d, want 2/1", env.Data.AddedLines, env.Data.DeletedLines)
}
if len(env.Data.Hunks) != 1 {
t.Fatalf("len(hunks) = %d, want 1", len(env.Data.Hunks))
}
if !strings.Contains(env.Data.Diff, "@@") || !strings.Contains(env.Data.Diff, "+- gamma") {
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
}
}
func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n\nhello old\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
if err := os.WriteFile("local.md", []byte("# Title\n\nhello new\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--file", "./local.md",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "@@") {
t.Fatalf("pretty output missing hunk header: %s", stdout.String())
}
if !strings.Contains(stdout.String(), output.Red+"-hello old"+output.Reset) {
t.Fatalf("pretty output missing removed line color: %q", stdout.String())
}
if !strings.Contains(stdout.String(), output.Green+"+hello new"+output.Reset) {
t.Fatalf("pretty output missing added line color: %q", stdout.String())
}
}
func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
if err := os.WriteFile("local.md", []byte("# Title\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--file", "./local.md",
"--as", "bot",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "remote Markdown content exceeds 10.0 MB markdown +diff content limit") {
t.Fatalf("expected remote content size error, got %v", err)
}
}
func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n"),
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
if err := os.WriteFile("local.md", bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--file", "./local.md",
"--as", "bot",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "local Markdown file exceeds 10.0 MB markdown +diff content limit") {
t.Fatalf("expected local content size error, got %v", err)
}
}
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
}
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
}
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
t.Fatalf("wrapped error = %q", got.Error())
}
}
func TestMarkdownDiffIncludesNoNewlineMarker(t *testing.T) {
diffText, changed, added, deleted, hunks := summarizeMarkdownDiff(
"a/test.md",
"b/test.md",
"# Title\n\nhello old",
"# Title\n\nhello new",
3,
)
if !changed {
t.Fatalf("expected changed=true")
}
if added != 1 || deleted != 1 {
t.Fatalf("added/deleted = %d/%d, want 1/1", added, deleted)
}
if len(hunks) != 1 {
t.Fatalf("len(hunks) = %d, want 1", len(hunks))
}
if strings.Count(diffText, "\\ No newline at end of file") != 2 {
t.Fatalf("diff should contain two no-newline markers: %q", diffText)
}
if !strings.Contains(diffText, "-hello old\n\\ No newline at end of file\n+hello new\n\\ No newline at end of file\n") {
t.Fatalf("diff missing expected no-newline marker sequence: %q", diffText)
}
}
func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
Status: 200,
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--to-version", "7633658129540910628",
"--context-lines", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Changed bool `json:"changed"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Hunks []markdownDiffHunk `json:"hunks"`
Diff string `json:"diff"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
}
if !env.OK || !env.Data.Changed {
t.Fatalf("expected changed=true: %s", stdout.String())
}
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 2 {
t.Fatalf("added/deleted = %d/%d, want 2/2", env.Data.AddedLines, env.Data.DeletedLines)
}
if len(env.Data.Hunks) != 2 {
t.Fatalf("len(hunks) = %d, want 2", len(env.Data.Hunks))
}
if !strings.Contains(env.Data.Diff, "-line2") || !strings.Contains(env.Data.Diff, "+line5 changed") {
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
}
}
func TestMarkdownDiffNoChangesPretty(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n"),
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n"),
})
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "No differences." {
t.Fatalf("pretty no-change output = %q, want %q", got, "No differences.")
}
}
func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
localPath := filepath.Join(".", "local.md")
if err := os.WriteFile(localPath, []byte("# local\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--file", localPath,
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
t.Fatalf("dry-run missing download call: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())
}
}

View File

@@ -182,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
want := []string{"+create", "+diff", "+fetch", "+patch", "+overwrite"}
if len(got) != len(want) {
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
@@ -269,6 +269,27 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
},
want: "--folder-token cannot be empty",
},
{
name: "wiki token cannot be empty",
args: []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token=",
},
want: "--wiki-token cannot be empty",
},
{
name: "folder and wiki tokens are mutually exclusive",
args: []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "fld_target",
"--wiki-token", "wikcn_target",
},
want: "--folder-token and --wiki-token are mutually exclusive",
},
{
name: "folder token must be valid",
args: []string{
@@ -279,6 +300,16 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
},
want: "--folder-token",
},
{
name: "wiki token must be valid",
args: []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "../bad",
},
want: "--wiki-token",
},
{
name: "content mode still validates markdown file name",
args: []string{
@@ -372,11 +403,40 @@ func TestMarkdownCreateDryRunWithInlineContent(t *testing.T) {
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_all") {
t.Fatalf("dry-run missing upload_all: %s", out)
}
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
}
if !strings.Contains(out, "markdown content") {
t.Fatalf("dry-run missing content marker: %s", out)
}
}
func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "wikcn_markdown_dryrun_target",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"parent_type": "wiki"`) {
t.Fatalf("dry-run missing wiki parent_type: %s", out)
}
if !strings.Contains(out, `"parent_node": "wikcn_markdown_dryrun_target"`) {
t.Fatalf("dry-run missing wiki parent_node: %s", out)
}
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
}
}
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
@@ -416,6 +476,9 @@ func TestMarkdownCreateDryRunWithFileUsesStatOnly(t *testing.T) {
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_prepare") {
t.Fatalf("dry-run missing multipart prepare step: %s", out)
}
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
}
if strings.Contains(out, "open should not be called in dry-run") {
t.Fatalf("dry-run unexpectedly tried to open the source file: %s", out)
}
@@ -435,6 +498,18 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
},
}
reg.Register(uploadStub)
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{}{
{"doc_token": "box_md_create", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
@@ -467,11 +542,60 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
if !strings.Contains(stdout.String(), `"file_name": "README.md"`) {
t.Fatalf("stdout missing file_name: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"url": "https://www.feishu.cn/file/box_md_create"`) {
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create"`) {
t.Fatalf("stdout missing url: %s", stdout.String())
}
}
func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
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_create_wiki",
"version": "1002",
},
},
}
reg.Register(uploadStub)
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{}{
{"doc_token": "box_md_create_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_wiki"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
"--wiki-token", "wikcn_markdown_create_target",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := body.Fields["parent_type"]; got != markdownUploadParentTypeWiki {
t.Fatalf("parent_type = %q, want %q", got, markdownUploadParentTypeWiki)
}
if got := body.Fields["parent_node"]; got != "wikcn_markdown_create_target" {
t.Fatalf("parent_node = %q, want %q", got, "wikcn_markdown_create_target")
}
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create_wiki"`) {
t.Fatalf("stdout missing metadata url for wiki-hosted markdown file: %s", stdout.String())
}
}
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
@@ -484,6 +608,18 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
},
},
})
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{}{
{"doc_token": "box_md_create_pretty", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_pretty"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
@@ -500,7 +636,7 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
if !strings.Contains(out, "file_token: box_md_create_pretty") {
t.Fatalf("pretty output missing file_token: %s", out)
}
if !strings.Contains(out, "url: https://www.feishu.cn/file/box_md_create_pretty") {
if !strings.Contains(out, "url: https://tenant.example.com/file/box_md_create_pretty") {
t.Fatalf("pretty output missing url: %s", out)
}
if !strings.Contains(out, "permission_grant.status: skipped") {
@@ -558,6 +694,18 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
},
},
})
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{}{
{"doc_token": "box_md_multipart", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart"},
},
},
},
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
@@ -586,6 +734,96 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
if !strings.Contains(stdout.String(), `"file_token": "box_md_multipart"`) {
t.Fatalf("stdout missing multipart file_token: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart"`) {
t.Fatalf("stdout missing multipart metadata url: %s", stdout.String())
}
}
func TestMarkdownCreateMultipartUploadToWikiUsesWikiParent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
prepareStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_markdown_wiki_ok",
"block_size": float64(markdownSinglePartSizeLimit),
"block_num": float64(2),
},
},
}
reg.Register(prepareStub)
uploadPartStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Reusable: true,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
}
reg.Register(uploadPartStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_multipart_wiki",
"version": "1005",
},
},
})
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{}{
{"doc_token": "box_md_multipart_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart_wiki"},
},
},
},
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
fh, err := os.Create("large.md")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(markdownSinglePartSizeLimit + 1); err != nil {
fh.Close()
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--file", "large.md",
"--wiki-token", "wikcn_markdown_multipart_target",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(prepareStub.CapturedBody, &body); err != nil {
t.Fatalf("decode upload_prepare body: %v\nraw=%s", err, string(prepareStub.CapturedBody))
}
if got := body["parent_type"]; got != markdownUploadParentTypeWiki {
t.Fatalf("parent_type = %#v, want %q", got, markdownUploadParentTypeWiki)
}
if got := body["parent_node"]; got != "wikcn_markdown_multipart_target" {
t.Fatalf("parent_node = %#v, want %q", got, "wikcn_markdown_multipart_target")
}
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart_wiki"`) {
t.Fatalf("stdout missing metadata url for wiki-hosted multipart markdown file: %s", stdout.String())
}
}
func TestMarkdownCreateFailsWhenMultipartPlanIsTooSmall(t *testing.T) {

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownDiff,
MarkdownFetch,
MarkdownPatch,
MarkdownOverwrite,

View File

@@ -16,6 +16,7 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
for _, path := range [][]string{
{"markdown", "+create"},
{"markdown", "+diff"},
{"markdown", "+fetch"},
{"markdown", "+overwrite"},
} {

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
@@ -29,6 +30,15 @@ func TestSheetCreateSheetValidateMissingToken(t *testing.T) {
}
}
func TestSheetInfoRequiresSpreadsheetMetaAndReadScopes(t *testing.T) {
t.Parallel()
want := []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"}
if !reflect.DeepEqual(SheetInfo.Scopes, want) {
t.Fatalf("SheetInfo scopes = %v, want %v", SheetInfo.Scopes, want)
}
}
func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) {
t.Parallel()

View File

@@ -22,9 +22,9 @@ import (
var SheetInfo = common.Shortcut{
Service: "sheets",
Command: "+info",
Description: "View spreadsheet and sheet information",
Description: "View spreadsheet metadata and sheet information",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
Scopes: []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},

View File

@@ -109,10 +109,5 @@ Drive Folder (云空间文件夹)
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。
- 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。
- `docs +search` 不是只搜文档 / Wiki结果里会直接返回 `SHEET` 等云空间对象。
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
## 补充说明
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。

View File

@@ -1,7 +1,5 @@
## 快速决策
- 按标题或关键词找云空间里的表格文件,先用 `lark-cli docs +search`
- `docs +search` 会直接返回 `SHEET` 结果,不要把它误解成只能搜文档 / Wiki。
- 已知 spreadsheet URL / token 后,再进入 `sheets +info``sheets +read``sheets +find` 等对象内部操作。
## 核心概念

View File

@@ -13,7 +13,7 @@ metadata:
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
> **查询类任务必做:** 涉及筛选、排序、Top/Bottom N、聚合、多表关联、查询后写入或判断全局结论时必须先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),再选择 `record / view / data-query` 路径。
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;如需先解析 Wiki 链接,可先调用 `lark-cli wiki ...`。
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;解析 Wiki 链接使用 `lark-cli wiki +node-get`。
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
## 1. 何时使用本 Skill
@@ -39,11 +39,12 @@ metadata:
### 1.2 前置约束
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令;如果输入是 Wiki 链接,可先调用 `lark-cli wiki spaces get_node` 解析真实 token
3. 定位到命令后,先读该命令对应的 reference再执行命令
4. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作
5. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`
6. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` 搜索 `BITABLE` 资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md):标题精确匹配、限定创建者/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令。
3. 如果输入是 Wiki 链接或 Wiki token并且用户想读取/操作其中的 Base先执行 `lark-cli wiki +node-get --token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token
4. 定位到命令后,先读该命令对应的 reference再执行命令
5. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作
6. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`
7. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli drive +search --query <keyword> --doc-types bitable` 搜索 Base / 多维表格资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md):标题精确匹配、限定 owner`--mine` / `--creator-ids`owner 语义非"最初创建人"/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。
## 2. 模块与命令导航
@@ -69,7 +70,7 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
| `lark-cli drive +search --query <keyword> --doc-types bitable` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference`--folder-token``--time-zone` 都是可选项 |
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference复制成功后应主动返回新 Base 标识信息 |
@@ -188,6 +189,8 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+form-list / +form-get` | 列出表单,或获取单个表单 | [`lark-base-form-list.md`](references/lark-base-form-list.md)、[`lark-base-form-get.md`](references/lark-base-form-get.md) | `+form-list` 可用来获取 `form-id``+form-get` 适合查看已有表单配置 |
| `+form-detail` | 通过表单分享链接获取表单详情(含题目列表、字段类型、校验规则) | [`lark-base-form-detail.md`](references/lark-base-form-detail.md) | 只读;仅需 `--share-token`(从分享链接提取),不需要 base-token/table-id/form-id返回的 `questions` 可直接用于 `+form-submit` 构造参数 |
| `+form-submit` | 通过表单分享链接填写并提交表单(支持普通字段 + 附件上传) | [`lark-base-form-submit.md`](references/lark-base-form-submit.md) | 写入操作;仅支持 share_token 模式;**当 `--json` 包含 attachments 时必须额外提供 `--base-token`**(附件上传到 Base Drive Media 需要);附件通过 `--json.attachments` 传入本地路径CLI 自动并行上传 |
| `+form-create / +form-update / +form-delete` | 创建、更新或删除表单 | [`lark-base-form-create.md`](references/lark-base-form-create.md)、[`lark-base-form-update.md`](references/lark-base-form-update.md)、[`lark-base-form-delete.md`](references/lark-base-form-delete.md) | 创建后可继续进入表单问题相关操作;更新或删除前先确认目标表单 |
| `+form-questions-list` | 列出表单题目 | [`lark-base-form-questions-list.md`](references/lark-base-form-questions-list.md) | 适合查看已有题目结构 |
| `+form-questions-create / +form-questions-update / +form-questions-delete` | 创建、更新或删除题目 | [`lark-base-form-questions-create.md`](references/lark-base-form-questions-create.md)、[`lark-base-form-questions-update.md`](references/lark-base-form-questions-update.md)、[`lark-base-form-questions-delete.md`](references/lark-base-form-questions-delete.md) | 先确认 `form-id`;更新或删除前先确认题目目标 |
@@ -254,11 +257,17 @@ metadata:
| 输入类型 | 正确处理方式 | 说明 |
|---------|--------------|------|
| 直接 Base 链接 `/base/{token}` | 直接提取 token 作为 `--base-token` | 不要把完整 URL 直接作为 `--base-token` |
| Wiki 链接 `/wiki/{token}` | 先 `wiki.spaces.get_node`,再取 `node.obj_token` | 不要把 `wiki_token` 直接当 `--base-token` |
| Wiki 链接 `/wiki/{token}` | 先用下方 fast path 解析 `data.obj_token` | 不要把 `wiki_token` 直接当 `--base-token`;如果这一步失败,再看 [`lark-wiki-node-get.md`](../lark-wiki/references/lark-wiki-node-get.md) |
| URL 中的 `?table={id}` | 先按前缀判断对象类型 | `tbl` 开头表示数据表 `table-id`,可作为 `--table-id``blk` 开头表示仪表盘 `dashboard-ID``wkf` 开头表示 `workflow-ID``ldx` 开头表示内嵌文档,不要一律当成 `--table-id` |
| URL 中的 `?view={id}` | 提取为 `--view-id` | 适合直接定位视图 |
| `lark-cli wiki spaces get_node` 返回的 `obj_type` | 后续路线 | 说明 |
Wiki Base fast path:
```bash
BASE_TOKEN="$(lark-cli wiki +node-get --as user --token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
```
| `lark-cli wiki +node-get` 返回的 `data.obj_type` | 后续路线 | 说明 |
|-----------------------------------------------|----------|------|
| `bitable` | 优先走 `lark-cli base +...` | 如果 shortcut 不覆盖,再用 `lark-cli base <resource> <method>`;不要改走 `lark-cli api /open-apis/bitable/v1/...` |
| `docx` | 转到文档 / Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
@@ -341,7 +350,7 @@ lark-cli auth login --domain base
| `1254066` | 人员字段错误 | `[{ "id": "ou_xxx" }]` |
| `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) |
| `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki spaces get_node` 取真实 `obj_token`;当 `obj_type=bitable` 时,用 `node.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
| `ignored_fields` / `READONLY` | 只读字段被当成可写字段常见于系统字段、formula、lookup | 移除只读字段,只写存储字段;计算结果交给 formula / lookup / 系统字段自动产出 |

View File

@@ -0,0 +1,319 @@
# base +form-detail
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
通过表单分享 Token 获取表单详情(含表单元信息、题目详情)。只读操作,不修改任何数据。
`+form-get` 的区别:`+form-get` 需要 `base-token` + `table-id` + `form-id`(从 Base 内部获取);`+form-detail` 仅需 `share-token`(从分享链接获取,无需知道 Base/表信息)。
## 命令
```bash
# 通过 share_token 获取表单详情
lark-cli base +form-detail \
--share-token <share_token>
# 以 pretty 格式展示(适合阅读 questions 结构)
lark-cli base +form-detail \
--share-token <share_token> \
--format pretty
# 使用 jq 过滤只看题目列表
lark-cli base +form-detail \
--share-token <share_token> \
--jq '.data.questions'
# 预览 API 调用(不执行)
lark-cli base +form-detail \
--share-token <share_token> \
--dry-run
# 使用应用身份bot
lark-cli base +form-detail \
--share-token <share_token> \
--as bot
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--share-token <token>` | 是 | 表单分享 Token从表单分享链接中提取 |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
| `--as` | 否 | 身份user默认\| bot |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
| `--jq <expr>` | 否 | 用 jq 表达式过滤 JSON 输出 |
### 从分享链接提取 share-token
用户提供形如以下格式的表单分享链接时:
```text
https://bitable-test.feishu-boe.cn/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
```
**提取方式:** 取 URL 路径最后一段作为 `--share-token`
以上述链接为例:
- `share-token` = `shrbcvST8eZy0vk8zjVZ1CAXNye`
```bash
lark-cli base +form-detail \
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye
```
## 输出格式
| 字段 | 类型 | 说明 |
|------|------|------|
| `base_token` | string | 所属多维表格 Base token |
| `name` | string | 表单名称 |
| `description` | string | 表单描述 |
| `questions[]` | array | 题目列表(含 id / title / type / required / description / filter |
### questions 中每个题目的字段
#### 固定字段(所有题目共有)
| 字段 | 类型 | 是否必填 | 说明 |
|------|------|----------|------|
| `id` | string | 是 | 题目标识(对应 field_id |
| `title` | string | 是 | 题目标题 |
| `type` | string | 是 | 字段类型(见下方类型对照表,与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 对齐) |
| `required` | bool | 是 | 是否必填 |
| `description` | string | 否 | 题目描述 |
| `filter` | object | 否 | 题目显示条件(详见下方 filter 结构说明) |
#### 动态字段(按 type 不同而不同,直接平铺在 question 中)
除上述固定字段外,每种 `type` 还会携带该类型特有的配置字段(与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 中的「常见补充字段」对应),例如:
- **text** → `style`(含 `style.type`: plain / phone / url / email / barcode
- **number** → `style`(含 `style.type`: plain / currency / progress / rating 及其子配置)
- **select** → `multiple`bool`options`(选项列表)或 `dynamic_options_source`
- **datetime / created_at / updated_at** → `style.format`
- **user / group_chat** → `multiple`
- **link** → `link_table``bidirectional``bidirectional_link_field_name`
- **formula** → `expression`
- **lookup** → `from``select``where``aggregate`
- **auto_number** → `style.rules`
- **attachment / location / checkbox / stage / created_by / updated_by** → 无额外动态字段
### filter 结构说明
`filter` 控制题目在表单中的显示/隐藏逻辑,由 `conjunction`(逻辑关系)和 `conditions`(条件列表)组成。
以下以一个「活动报名」表单为例,其中「紧急联系人」题目的 filter 配置:
```json
{
"conjunction": "and",
"conditions": [
{"field_name": "是否携带家属", "operator": "is", "value": ["是"]},
{"field_name": "参与人数", "operator": "isGreater", "value": [1]}
]
}
```
> 以上述 JSON 为例:当题目「是否携带家属」的值为「是」**并且**题目「参与人数」大于 1 时,「紧急联系人」才会展示(`conjunction: "and"` 表示全部条件需同时满足;若为 `"or"` 则任一条件满足即显示)。
另一个常见场景——用 `or` 控制可选填的补充信息:
```json
{
"conjunction": "or",
"conditions": [
{"field_name": "满意度评分", "operator": "isLessEqual", "value": [3]},
{"field_name": "是否愿意回访", "operator": "is", "value": ["是"]}
]
}
```
> 即:评分 ≤ 3 **或** 愿意接受回访时,才展示「改进建议」文本框。
#### filter 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `conjunction` | string | 条件间逻辑关系:`and`(全部满足) / `or`(任一满足) |
| `conditions[]` | array | 条件列表 |
#### conditions 中每个条件项的字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `field_name` | string | 所依赖的题目标题(引用其他题目的 title |
| `operator` | string | 过滤操作符(见下方 operator 可选值) |
| `value` | array | 过滤值数组(部分 operator 不需要,如 `isEmpty` / `isNotEmpty` |
#### operator 可选值
| operator | 含义 | 适用类型 |
|----------|------|----------|
| `is` | 等于 | 除附件外全部 |
| `isNot` | 不等于 | 除附件外全部 |
| `contains` | 包含 | 文本、选项、人员、群聊、地理位置 |
| `doesNotContain` | 不包含 | 文本、选项、人员、群聊、地理位置 |
| `isEmpty` | 为空 | 全部 |
| `isNotEmpty` | 不为空 | 全部 |
| `isGreater` | 大于 | 数字、日期时间 |
| `isGreaterEqual` | 大于等于 | 数字、日期时间 |
| `isLess` | 小于 | 数字、日期时间 |
| `isLessEqual` | 小于等于 | 数字、日期时间 |
> **附件attachment特殊说明** 仅支持 `isEmpty` 和 `isNotEmpty`,不支持 `is` / `isNot` / `contains` 及比较操作符。
#### value 的格式(按所依赖题目的类型区分)
| 所依赖题目类型 | value 格式 | 示例 |
|----------------|-----------|------|
| 文本类text / phone / email / url 等) | 字符串数组 | `["1", "2"]` |
| 数字类number | 数字数组 | `[1, 2]` |
| 选项类select / multi_select | 选项名称数组 | `["选项A", "选项B"]` |
| 人员类user | open_id 数组 | `["ou_d57864434a537020cf7a4a681d393e2d"]` |
| 群聊类group_chat | open_id 数组 | `["oc_f62478de5cc958583191e778db972603"]` |
| 地理位置location | 地点名称数组 | `["北京总部"]` |
| 日期时间类datetime | 时间字符串数组,固定格式 `yyyy-MM-dd HH:mm:ss` | `["2026-05-07 14:30:00"]` |
| 关联link / duplexlink | 记录 ID 数组 | `["recxxxxxxx", "recyyyyyyy"]` |
### type 可选值
与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 中的字段类型完全对齐。
| type 值 | 含义 | 常见动态字段 |
|----------|------|-------------|
| `text` | 文本(含电话/邮箱/链接/条码等子类型) | `style` |
| `number` | 数字(含货币/进度/评分等子类型) | `style` |
| `select` | 选项(单选/多选由 `multiple` 区分) | `multiple``options` / `dynamic_options_source` |
| `datetime` | 日期时间 | `style.format` |
| `user` | 人员 | `multiple` |
| `group_chat` | 群组 | `multiple` |
| `attachment` | 附件 | 无 |
| `location` | 地理位置 | 无 |
| `checkbox` | 复选框 | 无 |
| `link` | 关联 | `link_table``bidirectional``bidirectional_link_field_name` |
| `formula` | 公式 | `expression` |
| `lookup` | 引用 | `from``select``where``aggregate` |
| `auto_number` | 自动编号 | `style.rules` |
| `created_at` | 创建时间 | `style.format` |
| `updated_at` | 更新时间 | `style.format` |
| `created_by` | 创建人 | 无 |
| `updated_by` | 更新人 | 无 |
| `stage` | 阶段 | 无 |
```json
{
"ok": true,
"data": {
"base_token": "DBALKJKLHDLJ",
"name": "2026 年度技术大会报名",
"description": "请填写参会信息,带 * 为必填项",
"questions": [
{
"id": "fldzaYFpb6",
"required": true,
"title": "姓名",
"type": "text"
},
{
"id": "fldCoBpOlx",
"required": true,
"title": "手机号",
"type": "text",
"style": { "type": "phone" }
},
{
"id": "fldmmhZFCs",
"required": false,
"title": "公司邮箱",
"type": "text",
"style": { "type": "email" }
},
{
"id": "fldhqmqCj8",
"required": true,
"title": "参会日期",
"type": "datetime",
"style": { "format": "yyyy-MM-dd" }
},
{
"id": "fldlyRrfrN",
"required": true,
"title": "参与人数",
"type": "number"
},
{
"id": "fldRakYky3",
"required": false,
"title": "是否携带家属",
"type": "select",
"multiple": false,
"options": [
{ "name": "是", "hue": "Green", "lightness": "Lighter" },
{ "name": "否", "hue": "Gray", "lightness": "Lighter" }
]
},
{
"id": "fldyrOO0X4",
"required": false,
"title": "紧急联系人",
"type": "text",
"filter": {
"conjunction": "and",
"conditions": [
{"field_name": "是否携带家属", "operator": "is", "value": ["是"]},
{"field_name": "参与人数", "operator": "isGreater", "value": [1]}
]
}
},
{
"id": "fldM9AsRc2",
"required": false,
"title": "上传简历",
"type": "attachment",
"filter": {
"conjunction": "or",
"conditions": [
{"field_name": "是否携带家属", "operator": "isNotEmpty"}
]
}
},
{
"id": "fldN7PsWx1",
"required": true,
"title": "所属部门",
"type": "user",
"multiple": false
},
{
"id": "fldKq3mTz8",
"required": true,
"title": "参会主题",
"type": "select",
"multiple": true,
"options": [
{ "name": "AI 与大模型", "hue": "Purple", "lightness": "Lighter" },
{ "name": "云原生", "hue": "Blue", "lightness": "Lighter" },
{ "name": "工程效能", "hue": "Orange", "lightness": "Lighter" },
{ "name": "前端技术", "hue": "Carmine", "lightness": "Lighter" }
]
}
]
}
}
```
## 提示
- `share_token` 从表单分享链接中提取,格式通常为 `shr` + 随机字符串(如 `shrbcvST8eZy0vk8zjVZ1CAXNye`
- 返回的 `questions` 列表可直接用于构造 `+form-submit``--json.fields` 参数
- `questions[].title` 对应题目标题,可用于 `+form-submit` 的字段名映射
- 如果需要通过 Base 内部路径操作表单,使用 `+form-get`(需要 base-token / table-id / form-id
- 权限要求:`base:form:read`
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-base-form-submit](lark-base-form-submit.md) — 获取详情后可用 submit 填写提交

View File

@@ -0,0 +1,171 @@
# base +form-submit
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
通过表单分享链接填写并提交多维表格表单。仅支持分享模式share_token支持填写普通字段值和上传本地文件作为附件。
## 填写前必读:先获取表单详情
**在调用 `+form-submit` 之前,必须先使用 [`+form-detail`](lark-base-form-detail.md) 获取表单详情。** 原因如下:
1. **字段类型匹配**:每个题目的 `type` 决定了值的格式(文本、数字、选项、人员、日期等),需根据类型正确构造 `fields` 中的值
2. **必填校验**:通过 `questions[].required` 判断哪些题目为必填项,避免遗漏
3. **显示条件过滤**:部分题目带有 `filter`(显示/隐藏逻辑),需根据用户已填的其他题目值判断该题目是否应该出现——**不应填写被 filter 隐藏的题目**
4. **获取 base_token附件场景必用**`+form-detail` 返回的 `data.base_token` 是该表单所属的多维表格标识。当表单包含附件字段时,提交时必须通过 `--base-token` 传入此值,因为附件需要上传到该 Base 的 Drive Media 中
典型流程:
```bash
# 1⃣ 先获取表单详情,了解所有题目
lark-cli base +form-detail --share-token <share_token>
# 2⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
# 3⃣ 再提交
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}'
```
详见 [`lark-base-form-detail.md`](lark-base-form-detail.md) 中的「questions 结构说明」和「filter 结构说明」。
## 命令
```bash
# 基本提交(填写普通字段)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
# 带附件提交(需要额外提供 --base-token
lark-cli base +form-submit \
--share-token <share_token> \
--base-token <base_token> \
--json '{
"fields": {"服务评分": 5, "评价内容": "好"},
"attachments": {
"附件字段名": ["./report.pdf", "./photo.png"],
"另一个附件字段": ["./doc.docx"]
}
}'
# 使用应用身份bot
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--as bot
# 预览 API 调用(不实际执行)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--share-token <token>` | 是 | 表单分享 Token必填从表单分享链接中提取 |
| `--base-token <token>` | 条件必填 | Base token**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
| `--as` | 否 | 身份user默认\| bot |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
### --json 结构说明
`--json` 是一个 JSON 对象,包含两个部分:
#### fields普通字段
`fields` 中的单元格值写法与 [`lark-base-cell-value.md`](lark-base-cell-value.md) 完全对齐,填写前应先阅读该文档了解各类型的构造规则:
```json
{
"文本字段": "Hello World",
"电话字段": "13800000000",
"超链接字段": "https://example.com",
"数字字段": 12.5,
"单选字段": "选项A",
"多选字段": ["选项A", "选项B"],
"时间字段": "2026-04-27 14:30:00",
"复选框字段": true,
"人员字段": [{ "id": "ou_7094d131420c8749632145f08fbf114a" }],
"关联字段": [{ "id": "recXXXXXXXXXXXX" }],
"地理位置字段": { "lng": 116.397428, "lat": 39.90923 }
}
```
> **注意:附件类型字段不要写在 `fields` 里。** `fields` 中不包含附件附件有独立的填写方式见下方「attachments附件上传」章节。
> 自动编号、公式、创建/修改人、创建/修改时间等系统字段会自动填入,无需手动传入。
#### attachments附件上传
**附件字段的填写方式与 `fields` 中的普通单元格完全不同**,不能在 `fields` 里传 `file_token` 或其他附件格式。必须将附件字段单独放在 `--json` 的顶层 `attachments` 对象中,值为**本地文件路径数组**(不是 token
```json
{
"attachments": {
"附件字段名": ["./report.pdf", "./photo.png"],
"另一个附件字段": ["./doc.docx"]
}
}
```
CLI 收到路径后会自动完成以下流程:
1. 校验所有文件(存在性、大小 ≤2GB、常规文件
2. 并行上传到 Base Drive Media并发上限 5跨字段重复路径自动去重
3. 获取 `file_token` 后合并到最终表单提交内容中
> 与 [`lark-base-cell-value.md`](lark-base-cell-value.md) 中 Record 场景的附件写法不同Record 写入时附件走独立的 `+record-upload-attachment` 命令;而 `+form-submit` 只需在 `attachments` 中传本地路径,上传由 CLI 内部自动完成。
### 从分享链接提取 share-token
用户提供形如以下格式的表单分享链接时:
```
https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
```
**提取方式:** 取 URL 路径最后一段作为 `--share-token`
以上述链接为例:
- `share-token` = `shrbcvST8eZy0vk8zjVZ1CAXNye`
```bash
lark-cli base +form-submit \
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
--json '{"fields":{...}}'
```
## 输出格式
| 字段 | 类型 | 说明 |
|------|------|------|
| `can_submit_again` | bool | 是否可以再次填写 |
```json
{
"ok": true,
"data": {
"can_submit_again": true
}
}
```
## 提示
- 本命令仅支持通过表单分享链接share_token提交不支持通过 base_token + table_id + view_id 方式提交
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
- 附件字段只需在 `--json.attachments` 中提供本地路径即可CLI 自动完成校验、并行上传、Token 获取和合并写入
- 限流:单应用 20 QPS单用户 5 QPS
- 权限要求:`base:form:update`;使用 attachments 时还需 `docs:document.media:upload`
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-base-form](references/lark-base-form.md) — 表单管理总览

View File

@@ -9,7 +9,8 @@ form 相关命令索引。
| 文档 | 命令 | 说明 |
|------|------|------|
| [lark-base-form-list.md](lark-base-form-list.md) | `+form-list` | 分页列出表单 |
| [lark-base-form-get.md](lark-base-form-get.md) | `+form-get` | 获取表单详情 |
| [lark-base-form-get.md](lark-base-form-get.md) | `+form-get` | 获取表单详情Base 内部路径) |
| [lark-base-form-detail.md](lark-base-form-detail.md) | `+form-detail` | 通过分享链接获取表单详情(含题目列表) |
| [lark-base-form-create.md](lark-base-form-create.md) | `+form-create` | 创建表单 |
| [lark-base-form-update.md](lark-base-form-update.md) | `+form-update` | 更新表单 |
| [lark-base-form-delete.md](lark-base-form-delete.md) | `+form-delete` | 删除表单 |

View File

@@ -38,8 +38,6 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户说"找一个表格""按名称搜电子表格""找报表""最近打开的表格""最近我编辑过的 xxx" → 直接用 `lark-cli drive +search`(参考 [`lark-drive`](../lark-drive/references/lark-drive-search.md))。**老的 `docs +search` 已进入维护期、后续会下线,不要再新增依赖。**
- `drive +search` 结果里会直接返回 `SHEET` / `Base` / `FOLDER` 等云空间对象,是资源发现的统一入口
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
- 文档内容中出现嵌入的 `<sheet>``<bitable>``<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
@@ -52,18 +50,16 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
**补充:** 云空间资源发现统一走 [`drive +search`](../lark-drive/references/lark-drive-search.md);当用户口头说"表格/报表/最近我编辑过的 xxx"时,也优先从 `drive +search` 开始。老的 `docs +search` 只在沿用 `--filter` JSON 的存量脚本里保留,后续会下线。
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-doc-search.md) | ⚠️ **Deprecated — use [`drive +search`](../lark-drive/references/lark-drive-search.md)**. Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search). Kept for back-compat; new flows should use the drive-scoped command with flat flags. |
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |

View File

@@ -1,217 +0,0 @@
# docs +search云空间搜索文档 / Wiki / 电子表格)
> ⚠️ **此命令进入维护期,后续会下线。新用法请使用 [`drive +search`](../../lark-drive/references/lark-drive-search.md)。**
>
> `drive +search` 把所有过滤条件扁平化为独立 flag`--edited-since` / `--mine` / `--doc-types` 等),面向自然语言场景设计,同时新增了 `my_edit_time`(我编辑过)、`my_comment_time`(我评论过)等维度。除非要沿用老脚本里的 `--filter` JSON否则**都应该切到 `drive +search`**。
>
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间对象。
虽然接口名是 `doc_wiki/search`,但命中结果不只限于文档 / Wiki也会返回 `SHEET``BITABLE``FOLDER` 等云空间对象。因此它适合作为云空间对象的资源发现入口:先定位文档、知识库节点、电子表格、多维表格、文件夹,以及用户以“表格 / 报表”方式描述的相关对象,再切回对应业务 skill 做对象内部操作。
该 shortcut 会:
- 未指定范围字段时,自动补齐 `doc_filter` / `wiki_filter`
- 自动将 `--filter` 中的公共字段同步到搜索范围对应的 filter`folder_tokens` 仅发到 `doc_filter``space_ids` 仅发到 `wiki_filter`
- 支持在 `filter.open_time` / `filter.create_time` 中使用 ISO 8601 时间,并自动转换为 Unix 秒
- 在返回结果中为 `*_time` 字段补充 `*_time_iso`(便于阅读)
- `title_highlighted` / `summary_highlighted` 可能包含高亮标签(如 `<h>` / `<hb>`
## 命令
> **关键约束:搜索关键词必须通过 `--query` 传递。**
> 正确:`lark-cli docs +search --query "方案"`
> 错误:`lark-cli docs +search 方案`
> `+search` 不接受“搜索词位置参数”这种写法;如果把关键词直接跟在命令后面,不会进入 `query`,会变成空搜或返回不符合预期的结果。
```bash
# 关键词搜索
lark-cli docs +search --query "季度总结"
# 搜标题里带“评测结果”的电子表格 / 文档
lark-cli docs +search --query "评测结果"
# 标题包含关键词(默认按关键词检索,不做精确标题匹配)
lark-cli docs +search --query "方案"
# 使用服务端标题限定语法
lark-cli docs +search --query 'intitle:方案'
# 精确短语匹配
lark-cli docs +search --query '"季度 总结"'
# 逻辑或 / 排除
lark-cli docs +search --query '方案 OR 草稿'
lark-cli docs +search --query '方案 -草稿'
# 标题精确短语匹配
lark-cli docs +search --query 'intitle:"季度总结"'
# 按最近打开时间过滤
lark-cli docs +search \
--query "方案" \
--filter '{"open_time":{"start":"2025-09-24T00:00:00+08:00","end":"2025-12-24T23:59:59+08:00"}}'
# 按文档所有者过滤creator_ids 传文档所有者 open_id不是邮箱 / user_id
lark-cli docs +search \
--query "季度总结" \
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"]}'
# 只搜索指定类型
lark-cli docs +search \
--query "评测结果" \
--filter '{"doc_types":["SHEET","DOCX"]}'
# 只在指定文件夹下搜索文档folder_token 通常来自 /drive/folder/<token>
lark-cli docs +search \
--query "方案" \
--filter '{"folder_tokens":["fld_123456"]}'
# 只搜标题,不搜正文 / 摘要
lark-cli docs +search \
--query "周报" \
--filter '{"only_title":true}'
# 只搜评论,不搜标题 / 正文
lark-cli docs +search \
--query "延期原因" \
--filter '{"only_comment":true}'
# 只搜索指定群会话里分享过的文档chat_id 最多 20 个)
lark-cli docs +search \
--query "方案" \
--filter '{"chat_ids":["oc_1234567890abcdef"]}'
# 只搜索指定分享者分享过的文档sharer_ids 传分享者 open_id最多 20 个)
lark-cli docs +search \
--query "复盘" \
--filter '{"sharer_ids":["ou_EXAMPLE_USER_ID"]}'
# 按创建时间过滤并指定排序方式
lark-cli docs +search \
--query "方案" \
--filter '{"create_time":{"start":"2026-01-01","end":"2026-03-31"},"sort_type":"CREATE_TIME"}'
# 组合多个筛选条件
lark-cli docs +search \
--query "项目复盘" \
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}'
# 只在指定知识空间下搜 Wiki
lark-cli docs +search \
--query "研发规范" \
--filter '{"space_ids":["space_1234567890fedcba"]}'
# 空搜(不传 query 或传空字符串):按最近浏览等默认规则返回
lark-cli docs +search
# 人类可读格式输出
lark-cli docs +search --query "OKR" --format pretty
# 返回原始 JSON并用 page_token 翻页
lark-cli docs +search --query "方案" --format json
lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--query <text>` | 否 | 搜索关键词。**支持高级 Boolean 语法**以提升搜索精度:<br>1. 使用空格表示 AND`方案 设计`)。<br>2. 使用 `OR` 表示逻辑或(如 `方案 OR 草稿`)。<br>3. 使用 `-` 表示排除(如 `方案 -草稿`)。<br>4. 使用双引号 `""` 表示精确匹配短语。<br>5. 使用 `intitle:` 限定关键词出现在标题中(如 `intitle:总结``intitle:"季度 总结"`)。不传/空字符串表示空搜。**凡是有关键词,都要显式通过 `--query` 传递,不要写成位置参数。** |
| `--filter <json>` | 否 | JSON 对象。公共字段默认同时应用到 `doc_filter` / `wiki_filter`;若传 `folder_tokens`,则只发 `doc_filter`;若传 `space_ids`,则只发 `wiki_filter`;两者不能同时传 |
| `--page-size <n>` | 否 | 每页数量(默认 15最大 20 |
| `--page-token <token>` | 否 | 翻页标记(配合 `has_more` 使用) |
| `--format` | 否 | 输出格式json默认 \| pretty |
## `--query` 高级语法
以下语法由服务端搜索能力处理,适合把过滤逻辑尽量下推到搜索侧:
- 空格表示 AND`方案 设计`
- `OR` 表示逻辑或:`方案 OR 草稿`
- `-` 表示排除:`方案 -草稿`
- 双引号表示精确短语:`"季度 总结"`
- `intitle:` 表示标题限定:`intitle:总结`
- 标题精确短语:`intitle:"季度总结"`
## `--filter` 字段速查
`--filter` 是一个 JSON 对象。大多数字段默认会同时作用于 `doc_filter``wiki_filter`;其中 `folder_tokens` 只用于文档侧,`space_ids` 只用于 Wiki 侧。
### 字段归属
- `doc_filter` / `wiki_filter` 公共字段:`creator_ids``doc_types``chat_ids``sharer_ids``only_title``only_comment``open_time``sort_type``create_time`
- `doc_filter` 独有字段:`folder_tokens`
- `wiki_filter` 独有字段:`space_ids`
- 如果传 `folder_tokens`shortcut 只发送 `doc_filter`
- 如果传 `space_ids`shortcut 只发送 `wiki_filter`
- 如果同时传 `folder_tokens``space_ids`shortcut 直接报错,不支持同时查询文档文件夹范围和知识空间范围
| 字段 | 作用范围 | 类型 | 说明 |
|------|----------|------|------|
| `creator_ids` | 文档 + Wiki | `string[]` | 所有者列表,**必须传 open_id**,不是 `user_id` / `union_id` / 邮箱。比如 `["ou_xxx"]`。如果只有姓名,先用 `lark-contact` 查 open_id |
| `doc_types` | 文档 + Wiki | `string[]` | 资源类型过滤。常用值:`DOC``DOCX``SHEET``BITABLE``FILE``WIKI``SLIDES``FOLDER``CATALOG``SHORTCUT` |
| `chat_ids` | 文档 + Wiki | `string[]` | 群会话 ID 列表,只搜索这些会话里分享过的文档,最多 20 个。通常传群 `chat_id`(如 `oc_xxx`);如果用户只给群名,先用 `lark-im` 定位群 |
| `sharer_ids` | 文档 + Wiki | `string[]` | 分享者列表,**必须传分享者 open_id**,最多 20 个。适合“某人分享过的文档”;如果只有姓名,先用 `lark-contact` 查 open_id |
| `folder_tokens` | 仅文档 | `string[]` | 只搜索指定云空间文件夹下的文档;值通常来自文件夹 URL `/drive/folder/<folder_token>` |
| `space_ids` | 仅 Wiki | `string[]` | 只搜索指定知识空间下的 Wiki 节点 |
| `only_title` | 文档 + Wiki | `boolean` | 只搜标题。注意这不是“标题精确等于”,只是把搜索范围限制在标题 |
| `only_comment` | 文档 + Wiki | `boolean` | 只搜评论。用法类似 `only_title`,只是把搜索范围限制在评论区;默认 `false` |
| `open_time` | 文档 + Wiki | `object` | 最近打开时间范围,支持 `{ "start": "...", "end": "..." }`。shortcut 支持 ISO 8601 / `YYYY-MM-DD` / Unix 秒,并自动转成秒级时间戳 |
| `sort_type` | 文档 + Wiki | `string` | 排序方式。常用值:`DEFAULT_TYPE``OPEN_TIME``EDIT_TIME``EDIT_TIME_ASC``CREATE_TIME` |
| `create_time` | 文档 + Wiki | `object` | 文档 / Wiki 创建时间范围,结构与 `open_time` 相同 |
### 字段使用建议
- `creator_ids`:适合“找某个人创建的文档 / 表格 / Wiki”。如果用户只给姓名不要猜 ID先查这个人的 `open_id`
- `doc_types`:只在用户**明确指定资源类型**时使用,适合先把资源类型缩小。显式类型词可按以下方式映射:`表格 / 电子表格 / spreadsheet -> ["SHEET"]``多维表格 / base / bitable -> ["BITABLE"]``知识库 / wiki -> ["WIKI"]``文件夹 -> ["FOLDER"]``普通文档` 或明确要求“只看文档类型、不要表格 / Wiki” -> `["DOC","DOCX"]`。不要因为用户口头说“文档”就默认补 `DOC` / `DOCX`,因为“文档”在很多场景里只是对云空间对象的泛称。
- `chat_ids`:适合“搜某个群里分享过的文档”“看某个群会话里的方案”。如果用户只给群名,先切到 `lark-im` 用群搜索能力拿到 `chat_id`,再回到 `docs +search`
- `sharer_ids`:适合“找某人分享过的文档”“看某个同事转给我的资料”。如果用户只给姓名,不要猜 ID先用 `lark-contact` 查分享者 `open_id`
- `folder_tokens`:适合“在某个云空间文件夹里搜文档”。它不是知识空间 `space_id`,两者不要混用。
- `only_title`:适合“标题里包含某个词”的场景;如果用户明确表达标题限定,也可以直接在 `--query` 里使用 `intitle:`。如果用户要“标题精确等于”,优先使用 `intitle:"完整标题"`,必要时再做客户端精确确认。
- `only_comment`:适合“评论里提到某个词”“只找评论区讨论过某件事”。它和 `only_title` 一样,都是把搜索范围缩小到特定区域,但这里限制到评论区。
- `open_time`:适合“最近打开过 / 最近看过”的描述;如果用户说相对时间,先换算成明确绝对时间再传。
- `sort_type``CREATE_TIME_ASC` 在协议里标注“暂不支持”,`ENTITY_CREATE_TIME_ASC` / `ENTITY_CREATE_TIME_DESC` 已废弃,默认不要主动使用。
- `create_time`:适合“今年新建的”“上个月创建的”这类条件;不写 `start` / `end` 时,协议默认窗口是“请求时间往前 1 年”到“请求时间”。
### 常见 `--filter` JSON 片段
```json
{"creator_ids":["ou_EXAMPLE_USER_ID"]}
{"doc_types":["SHEET","DOCX"]}
{"chat_ids":["oc_1234567890abcdef"]}
{"sharer_ids":["ou_EXAMPLE_USER_ID"]}
{"folder_tokens":["fld_123456"]}
{"only_title":true}
{"only_comment":true}
{"open_time":{"start":"2026-01-01T00:00:00+08:00","end":"2026-03-31T23:59:59+08:00"},"sort_type":"OPEN_TIME"}
{"create_time":{"start":"2026-01-01","end":"2026-03-31"},"sort_type":"CREATE_TIME"}
{"space_ids":["space_1234567890fedcba"]}
```
## 结果判别
- `result_meta.doc_types == SHEET`:电子表格,后续切到 `lark-sheets`
- 其他类型:继续按对应 skill 或 API 处理
## 决策规则
- 参数传递:只要用户给了搜索关键词,就必须显式使用 `--query "<关键词>"`。不要生成 `lark-cli docs +search 方案``lark-cli docs +search xxx搜索关键词` 这种位置参数写法。
- 查询语义:必须优先利用 --query 的高级语法(如 intitle:、""、-)将过滤逻辑下推给服务端。当用户要求“标题精确等于 X”时直接使用 --query "intitle:\"X\"",严禁先进行模糊搜索再做客户端二次筛选。只有在遇到服务端语法无法覆盖的复杂本地比对场景时,才允许在客户端过滤,且比对前必须先去掉 title_highlighted 里的高亮标签。
- 实体补全:如果用户要按“某个群里分享的文档”搜索,先用 `lark-im``chat_id` 再填 `chat_ids`;如果用户要按“某人分享的文档”搜索,先用 `lark-contact``open_id` 再填 `sharer_ids`
- 零结果回退:如果因为用户的显式类型约束加了 `doc_types` 且结果为 0可以提示“按指定类型没搜到”只有在不违背用户明确约束的前提下才建议放宽类型重试。
- 入口选择:用户说“找表格标题”“找名为 `X` 的电子表格”“搜某个报表”时,也默认走 `docs +search`。不要误用 `sheets +find` 做跨文件搜索。
- 分页策略:默认只返回**第一页**,并说明 `has_more` / `page_token`。只有当用户明确要求“全部结果”“继续翻页”“全量扫描”“所有结果”“完整列表”时,才继续翻页。
- 翻页上限:即使用户要求全量,单轮也最多先拉 **5 页**(按默认 `page-size=20` 约等于最多 100 条结果)。达到上限后,先回报当前进度和是否还有更多页,再让用户决定是否继续下一批。
- 总数口径:`total` 是 OpenAPI 的搜索结果总数,不一定等于客户端二次筛选后的精确数量。凡是依赖本地过滤、去重、精确标题匹配的场景,都不要默认承诺“精确总数”。
- 原始返回:如果用户要求“直接返回接口数据 / 原始返回”,优先使用 `--format json`,不要额外做精确标题过滤或摘要重写。
- 时间表达用户如果说“3 到 6 个月前”“最近半年内”等相对时间,先转换成明确的绝对时间,再写入 `filter.open_time` / `filter.create_time`
- 跨 skill handoff如果搜索的目标是某个 spreadsheet返回命中的标题、URL、token 等定位信息后,应切换到 `lark-sheets` 继续后续操作,不要把 `docs +search` 当成对象内部查询。
## 权限
| 操作 | 所需 scope |
|------|-----------|
| 搜索云空间对象(含文档 / Wiki / 表格资源发现) | `search:docs:read` |

View File

@@ -16,10 +16,11 @@ metadata:
## 快速决策
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `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`
@@ -129,7 +130,7 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`slides` 仅支持 block_id,且都支持最终解析到对应类型的 wiki URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`sheet` 使用 `<sheetId>!<cell>``slides` 使用 `<slide-block-type>!<xml-id>`,且都支持最终解析到对应类型的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
@@ -139,7 +140,8 @@ Drive Folder (云空间文件夹)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --api-version v2 --detail with-ids` 获取
- 局部评论:传 `--block-id` 时启用;不同文档类型的支持范围与参数格式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
@@ -182,6 +184,12 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
### 评论业务特性与引导(关键!)
#### Review 场景评论落点
- 默认策略是“能局部就局部”:用户说 review、审阅、检查文档、标注问题、给修改建议、逐条评论时优先创建局部评论。
- 多个独立问题应分别创建多条局部评论;不要为了省调用次数把 review 发现的问题合并到全文评论。
- 只有在目标类型支持全文评论,且出现以下任一情况时,才退回全文评论:用户明确要求全文/总体评论、评论内容确实是文档级总结、目标类型不支持局部评论,或无法稳定定位到具体位置;否则应说明限制并请求用户提供可定位位置。
- 具体参数、定位方式和不同文档类型的约束见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。
#### 评论排序引导
- 一个文档通常有多个评论,评论按 `create_time`(创建时间)排序。
- **重要**:只有当用户明确提到"最新评论"、"最后评论"、"最早评论"时,才需要根据 `create_time` 进行排序:
@@ -249,7 +257,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags (preferred over `docs +search`). Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags. Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |

View File

@@ -137,8 +137,9 @@ lark-cli drive +add-comment \
## 行为说明
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
- **Review 场景优先局部评论**:审阅、校对、逐条指出问题时,必须先尝试定位到具体 block / 单元格 / slide 元素,并逐问题创建局部评论;不要把所有问题合并成一条全文评论。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL以及最终可解析为 `doc`/`docx` 的 wiki URL。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式支持 `docx``slides`,以及最终可解析为这些类型的 wiki URL。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式支持 `docx``sheet``slides`,以及最终可解析为这些类型的 wiki URL。
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`sheet 没有全文评论,`--full-comment` 不可用。
- **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "<SLIDE_BLOCK_TYPE>!<XML_ELEMENT_ID>"`。CLI 会将其拆分映射到 `anchor.block_id` / `anchor.slide_block_type`。此时 `--full-comment``--selection-with-ellipsis` 不可用。
- **Slide 参数映射示例**`--block-id` 由 PPT XML 元素类型和元素 `id` 组成。例如:

View File

@@ -43,6 +43,9 @@ lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还
# 导入到指定文件夹,并指定导入后的文件名
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
# 导入数据到已有的多维表格(不新建,数据挂载到目标多维表格中)
lark-cli drive +import --file ./data.xlsx --type bitable --target-token <BASE_TOKEN>
# 预览底层调用链(上传 -> 创建任务 -> 轮询)
lark-cli drive +import --file ./README.md --type docx --dry-run
```
@@ -55,6 +58,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `--type` | 是 | 导入目标云文档格式。可选值:`docx` (新版文档)、`sheet` (电子表格)、`bitable` (多维表格) |
| `--folder-token` | 否 | 目标文件夹 token不传则请求中的 `point.mount_key` 为空字符串Import API 会将其解释为导入到云空间根目录 |
| `--name` | 否 | 导入后的在线云文档名称,不传默认使用本地文件名去掉扩展名后的结果 |
| `--target-token` | 否 | 已有的多维表格 token将数据导入到该多维表格中**仅支持 `--type bitable`**);传入后数据会挂载到目标多维表格而非新建一个 |
## 行为说明
@@ -64,7 +68,8 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
- 超过 20MB自动切换为分片上传 `upload_prepare -> upload_part -> upload_finish`
2. 调用 `import_tasks` 接口发起导入任务,自动根据本地文件提取扩展名并构造挂载点(`mount_point`)参数
3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令
- **默认根目录行为**:不传 `--folder-token`shortcut 会保留空的 `point.mount_key`Lark Import API 会将其视为导入到调用者根目录
- **默认根目录行为**:不传 `--folder-token`shortcut 会保留空的 `point.mount_key`Lark Import API 会将其视为"导入到调用者根目录"
- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格的 tokenpoint 挂载点逻辑不变。数据会挂载到该已有多维表格中,而非创建新文档。
### 支持的文件类型转换

View File

@@ -5,12 +5,12 @@
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间对象。
和老的 `docs +search` 相比
核心特性
- 把常用过滤条件全部**扁平化为独立 flag**`--edited-since``--mine``--doc-types``--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
- 额外暴露了 4 个"我"维度:`my_edit_time`(我编辑过)、`my_comment_time`(我评论过)、`open_time`(我打开过)、`create_time`(文档创建时间)——直接对应用户自然语言里的"最近我编辑过的"、"我评论过的"等表达
- 自动处理 `my_edit_time` / `my_comment_time` 的小时级聚合(服务端存储粒度):亚小时输入会向整点 snap并在 stderr 打出提示
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`,不必再先去查 contact
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`,不必再先去查 contact(注意 `creator_ids` 服务端按 **owner / 文档归属人** 语义匹配,不是“最初创建人”,详见下文「身份维度」)
> **资源发现入口统一**`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill如 `lark-sheets`)做对象内部操作。
@@ -20,20 +20,24 @@
> 正确:`lark-cli drive +search --query "方案"`
> 错误:`lark-cli drive +search 方案`
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
>
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
### 自然语言 → 命令映射速查
| 用户说 | 命令 |
|---|---|
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --mine --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
| 最近一个月我编辑过 且 我评论过的 | `lark-cli drive +search --query "" --edited-since 1m --commented-since 1m` |
| 最近一周我打开过的表格 | `lark-cli drive +search --query "" --opened-since 7d --doc-types sheet` |
| 我创建的所有文档 | `lark-cli drive +search --query "" --mine` |
| 我 30-60 天前创建的文档(粗略"上个月",按 30 天滑窗算) | `lark-cli drive +search --query "" --mine --created-since 2m --created-until 1m` |
| 我 2026 年 3 月创建的文档(精确日历月) | `lark-cli drive +search --query "" --mine --created-since 2026-03-01 --created-until 2026-04-01` |
| 我 owner 的所有文档owner 语义,非"我最初创建" | `lark-cli drive +search --query "" --mine` |
| 我 owner、30-60 天前创建的文档(粗略"上个月",按 30 天滑窗算`--mine` 是 owner`--created-*` 才是文档创建时间 | `lark-cli drive +search --query "" --mine --created-since 2m --created-until 1m` |
| 我 owner、2026 年 3 月创建的文档(精确日历月同上owner + 创建时间窗两个维度 | `lark-cli drive +search --query "" --mine --created-since 2026-03-01 --created-until 2026-04-01` |
| 关键词"预算",最近一周我打开过,按编辑时间降序 | `lark-cli drive +search --query 预算 --opened-since 7d --sort edit_time` |
| 某个 wiki space 下、我 30-60 天前创建的 | `lark-cli drive +search --query "" --mine --space-ids space_xxx --created-since 2m --created-until 1m` |
| 张三创建的文档 | `lark-cli drive +search --query "" --creator-ids ou_zhangsan` |
| 某个 wiki space 下、我 owner 且 30-60 天前创建的 | `lark-cli drive +search --query "" --mine --space-ids space_xxx --created-since 2m --created-until 1m` |
| 张三 owner / 负责的文档(注意是 owner 语义,不是张三最初创建的)| `lark-cli drive +search --query "" --creator-ids ou_zhangsan` |
| 我最近 3 个月评论过的 docx | `lark-cli drive +search --query "" --commented-since 3m --doc-types docx` |
### 更多示例
@@ -42,7 +46,7 @@
# 纯关键词搜索
lark-cli drive +search --query "季度总结"
# 使用服务端 query 高级语法(和 docs +search 一致)
# 使用服务端 query 高级语法
lark-cli drive +search --query 'intitle:方案'
lark-cli drive +search --query '"季度 总结"'
lark-cli drive +search --query '方案 OR 草稿'
@@ -69,6 +73,25 @@ lark-cli drive +search --query 方案 --format json
lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
```
### 列表 / 统计型请求的执行步骤
对"所有文档"、"按类型分类统计"、"最近更新过"这类请求,不要只跑一次搜索后直接回答。标准流程:
1. 先把自然语言拆成过滤条件:所有权(`--mine` / `--creator-ids`)、时间维度(`--created-*` / `--edited-*` / `--opened-*` / `--commented-*`)、类型(`--doc-types`)、空间或文件夹范围。
2. 没有真实业务关键词时保持 `--query ""`;不要把"所有文档"、"统计"、"最近更新"放进 query。
3. 检查返回结果的 `doc_type` / `result_meta.doc_types`、创建/编辑时间和 URL/token 是否与过滤目标一致;明显不符合的结果不要计入答案。
4. 用户要求"所有 / 全量 / 统计"时按 `has_more` 翻页并累积去重;不要只用第一页推断总量。返回体里的 `total` 不可靠,统计要以实际去重后的结果为准。
5. 汇总时按真实返回字段分组,例如按 `doc_type` 统计 DOCX、SHEET、BITABLE、WIKI、FILE 等,不要凭标题猜类型。
### 内容检索型请求的 query 扩展
用户问的是原因、结论、方案、对比等内容问题时,`--query` 应保留业务关键词,但不要只用整句原问。先用核心实体 + 主题词搜索,再按结果调整:
- "东南亚服务器成本为何较其他区域贵" → 先搜 `"东南亚 服务器 成本"`,如果召回不足,再搜 `"服务器 成本 区域"``"非洲 欧洲 服务器 成本"``"机房 成本 费用"` 等同主题扩展词。
- "某项目发布会重点" → 先搜项目名 + "发布会" + "重点/功能/一览",再按标题和摘要判断是否需要只搜标题或扩大到正文。
每轮扩展都要保留非污染、可解释的 evidenceURL/token/标题/摘要);不能因为某个扩展词搜到高相似标题就跳过证据核验。
## 参数
### 核心
@@ -80,12 +103,14 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
| `--format` | 否 | `json`(默认)/ `pretty` |
### 身份creator 维度
### 身份(owner 维度API 字段名 `creator_ids`
> **语义说明(重要)**`creator_ids`(含 `--mine` / `--creator-ids`)虽然 OpenAPI 字段名是 “creator”但服务端实际按 **owner文档归属人 / 负责人)** 语义匹配,**不是“最初创建人”**:我创建后转交他人的文档不会命中,他人创建后转给我(我成为 owner的会命中。用户说“我的 / 我创建的 / 我负责的”文档都路由到 `--mine`,但要清楚它返回的是“我 owner 的”。
| 参数 | 映射 | 说明 |
|---|---|---|
| `--mine` | `creator_ids = [当前用户 open_id]` | bool。一键"我创建的";从当前登录用户身份(`runtime.UserOpenId()`)解析 open_id取不到直接报错提示运行 `lark-cli auth login` |
| `--creator-ids ou_x,ou_y` | `creator_ids = [...]` | 显式 open_id 列表,逗号分隔;**与 `--mine` 互斥** |
| `--mine` | `creator_ids = [当前用户 open_id]` | bool。一键“我 owner 的”(**不是**“我最初创建的”);从当前登录用户身份(`runtime.UserOpenId()`)解析 open_id取不到直接报错提示运行 `lark-cli auth login` |
| `--creator-ids ou_x,ou_y` | `creator_ids = [...]` | 显式 open_id 列表,逗号分隔,按 **owner** 匹配**与 `--mine` 互斥** |
### 时间维度(每个维度一对 since/until
@@ -162,8 +187,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
## 决策规则
- **`docs +search` 的选择**:优先使用 `drive +search`(本指令),不要再用 `docs +search``docs +search` 进入维护期、后续会下线
- **身份快捷方式**:只要用户说"我创建的",直接 `--mine` 即可,不需要先查 contact 拿 open_id。
- **身份快捷方式**:用户说“我的 / 我创建的 / 我负责的”文档,直接 `--mine` 即可,不需要先查 contact 拿 open_id。注意 `--mine`**owner** 语义(我归属/负责的),不是“我最初创建的”——转交出去的不算、转交给我的算
- **时间维度选择**
- "我编辑的"、"我修改的" → `--edited-since` / `--edited-until`
- "我评论的"、"我回复过的" → `--commented-since` / `--commented-until`
@@ -173,14 +197,17 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
- "某个文件夹下" → `--folder-tokens`doc-only
- "某个 wiki 空间下" → `--space-ids`wiki-only
- 两者不能同时使用,混用会报错
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传,会直接报错。"我和张三创建的" `--creator-ids ou_me,ou_zhangsan`(需要先拿到自己 open_id但这种场景少见
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传,会直接报错。我和张三的”owner`--creator-ids ou_me,ou_zhangsan`(需要先拿到自己 open_id但这种场景少见
- **实体补全**
- 用户说"某个群里",先用 `lark-im``chat_id`
- 用户说"某人创建/分享的"(非自己),先用 `lark-contact` 查 open_id再填 `--creator-ids` / `--sharer-ids`
- 用户说某人的 / 某人分享的(非自己`--creator-ids` 按 owner 匹配),先用 `lark-contact` 查 open_id再填 `--creator-ids` / `--sharer-ids`
- **查询语义下推**`--query` 支持的服务端高级语法(`intitle:``""``OR``-`)优先使用,不要先模糊搜再在客户端二次过滤。
- **query 填写边界**:只有标题片段、业务名词、项目名、会议名、文件内容关键词才应进入 `--query`。仅描述动作、时间范围、所有权、统计方式的词不算关键词,保持 `--query ""` 并依赖 filters。
- **证据核验**:列表/统计类答案必须来自搜索结果中的实际 URL/token 和类型/时间字段;内容问答必须能指出使用了哪些非污染候选。没有可验证候选时先扩大 query 或翻页,不要直接编总结。
- **时间表达**
- 模糊相对时间("最近半年"、"过去 30 天"、"最近一周")→ `--*-since 6m` / `--*-since 30d` / `--*-since 7d`,不展开成 ISO 时间
- **日历表达**"上个月"、"上周"、"本月"、"前年"、"今年 3 月"等明确日历单位)→ **必须算出绝对 `YYYY-MM-DD` 边界**(如"上个月" = 上一个日历月的 1 号 → 当月 1 号),**不要近似成 `1m`/`2m`**CLI 里 `m` 是固定 30 天、`y` 固定 365 天,跟日历差 0-3 天,月末月初尤其容易偏出去
- 文档中的 `"<YYYY-MM-DD>"` 是运行时占位符:执行命令前按当前日期计算并替换。例如"本月"应替换为本月第一天和下月第一天,不要把示例生成时的月份硬编码进答案
- 绝对日期 → 直接 `YYYY-MM-DD` 或 RFC3339
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。只有用户明确要"全部 / 全量 / 继续翻"才继续。单轮翻页上限 5 页。
- **原始返回**:用户要求"原始数据"、"接口返回"时用 `--format json`,不做客户端精确过滤或摘要重写。

View File

@@ -27,7 +27,7 @@ When using `--as user`, the reply is sent as the authorized end user and require
| Reply with plain text exactly as written | `--text` | Wrapped directly to `{"text":"..."}` |
| Reply with simple Markdown and accept conversion | `--markdown` | Automatically converted to `post` JSON |
| Precisely control the reply payload | `--content` | You provide the exact JSON |
| Reply with media | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads local files automatically |
| Reply with media | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads URLs, or cwd-relative local files automatically |
### `--text` vs `--markdown`
@@ -138,24 +138,30 @@ lark-cli im +messages-reply --message-id om_xxx --text "Received" --idempotency-
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' --dry-run
```
## Media Input Rules
- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--message-id <id>` | Yes | ID of the message being replied to (`om_xxx`) |
| Parameter | Required | Description |
|------|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--message-id <id>` | Yes | ID of the message being replied to (`om_xxx`) |
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
| `--content <json>` | One content option | Exact reply content as JSON. The JSON must match the effective `--msg-type` |
| `--text <string>` | One content option | Plain text reply. Best default when you need exact text and formatting preservation |
| `--markdown <string>` | One content option | Convenience Markdown input. Internally converted to `post` JSON with Feishu-specific normalization |
| `--image <path\|key>` | One content option | Local image path or `image_key` (`img_xxx`) |
| `--file <path\|key>` | One content option | Local file path or `file_key` (`file_xxx`) |
| `--video <path\|key>` | One content option | Local video path or `file_key`; **must be used together with `--video-cover`** |
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path or `image_key` (`img_xxx`) |
| `--audio <path\|key>` | One content option | Local audio path or `file_key` |
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |
| `--content <json>` | One content option | Exact reply content as JSON. The JSON must match the effective `--msg-type` |
| `--text <string>` | One content option | Plain text reply. Best default when you need exact text and formatting preservation |
| `--markdown <string>` | One content option | Convenience Markdown input. Internally converted to `post` JSON with Feishu-specific normalization |
| `--image <path\|url\|key>` | One content option | Cwd-relative local image path, URL, or `image_key` (`img_xxx`) |
| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`) |
| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`); **must be used together with `--video-cover`** |
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`) |
| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`) |
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |
> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
>
@@ -211,7 +217,7 @@ The reply appears in the target message's thread and does not show up in the mai
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
- `--reply-in-thread` adds `reply_in_thread=true` to the API request
- `--reply-in-thread` is mainly meaningful in chats that support thread replies
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support existing keys, URLs, and cwd-relative local file paths; the shortcut uploads local paths and URLs first, then sends the reply; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
- `--markdown` always sends `msg_type=post`
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails

View File

@@ -27,7 +27,7 @@ When using `--as user`, the message is sent as the authorized end user and requi
| Send plain text exactly as written | `--text` | Wrapped directly to `{"text":"..."}`; no Markdown conversion |
| Send simple Markdown and accept Feishu-style rendering | `--markdown` | Automatically converted to `post` JSON |
| Precisely control the final payload | `--content` | You provide the exact JSON for `text` / `post` / `interactive` / `share_*` / media payloads |
| Send image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads local files automatically |
| Send image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads URLs, or cwd-relative local files automatically |
### `--text` vs `--markdown`
@@ -144,24 +144,30 @@ lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --idempotency-key my-
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry-run
```
## Media Input Rules
- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--chat-id <id>` | One of two | Group chat ID (`oc_xxx`) |
| `--user-id <id>` | One of two | User open_id (`ou_xxx`) for direct messages |
| `--text <string>` | One content option | Plain text message. Best default for exact text and preserved formatting. Automatically wrapped as `{"text":"..."}` |
| `--markdown <string>` | One content option | Convenience Markdown input. Internally converted to `post` JSON with Feishu-specific normalization; not full Markdown passthrough |
| `--content <json>` | One content option | Exact message content JSON string; use this when you need full control over `msg_type` and payload. The JSON must match the effective `--msg-type` |
| `--image <path\|key>` | One content option | Local image path or `image_key` (`img_xxx`). Local paths are uploaded automatically |
| `--file <path\|key>` | One content option | Local file path or `file_key` (`file_xxx`). Local paths are uploaded automatically |
| `--video <path\|key>` | One content option | Local video path or `file_key`. Local paths are uploaded automatically. **Must be paired with `--video-cover`** |
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path or `image_key` (`img_xxx`). Local paths are uploaded automatically |
| `--audio <path\|key>` | One content option | Local audio path or `file_key`. Local paths are uploaded automatically |
| Parameter | Required | Description |
|------|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--chat-id <id>` | One of two | Group chat ID (`oc_xxx`) |
| `--user-id <id>` | One of two | User open_id (`ou_xxx`) for direct messages |
| `--text <string>` | One content option | Plain text message. Best default for exact text and preserved formatting. Automatically wrapped as `{"text":"..."}` |
| `--markdown <string>` | One content option | Convenience Markdown input. Internally converted to `post` JSON with Feishu-specific normalization; not full Markdown passthrough |
| `--content <json>` | One content option | Exact message content JSON string; use this when you need full control over `msg_type` and payload. The JSON must match the effective `--msg-type` |
| `--image <path\|url\|key>` | One content option | Cwd-relative local image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically. **Must be paired with `--video-cover`** |
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |
> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
>
@@ -211,7 +217,7 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
- `--content` must be valid JSON
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
- `--image`/`--file`/`--video`/`--audio` support local file paths; the shortcut uploads first and then sends the message; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
- `--image`/`--file`/`--video`/`--audio` support existing keys, URLs, and cwd-relative local file paths; the shortcut uploads local paths and URLs first, then sends the message; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails

View File

@@ -1,7 +1,7 @@
---
name: lark-markdown
version: 1.1.0
description: "飞书 Markdown查看、创建、上传编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取修改时使用。"
version: 1.2.0
description: "飞书 Markdown查看、创建、上传编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取修改、局部 patch 或比较差异时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -15,9 +15,11 @@ metadata:
## 快速决策
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
- 用户要**比较原生 `.md` 文件的历史版本差异**,或比较远端 Markdown 与本地草稿,使用 `lark-cli markdown +diff`
- 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch`
- 用户要对 Markdown 文件做**局部文本替换 / 正则替换**,优先使用 `lark-cli markdown +patch`
- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite`
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
- 用户要把本地 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)
@@ -42,6 +44,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |
| [`+diff`](references/lark-markdown-diff.md) | Compare two remote Markdown versions, or compare remote Markdown against a local file |
| [`+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

@@ -2,7 +2,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
在 Drive 中创建一个原生 Markdown 文件(`.md`)。
在 Drive 中创建一个原生 Markdown 文件(`.md`,支持创建到普通 Drive 文件夹或 Wiki 节点下
## 命令
@@ -32,6 +32,11 @@ lark-cli markdown +create \
--folder-token fldcn_xxx \
--file ./README.md
# 创建到指定 wiki 节点
lark-cli markdown +create \
--wiki-token wikcn_xxx \
--file ./README.md
# 预览底层请求
lark-cli markdown +create \
--name README.md \
@@ -43,7 +48,8 @@ lark-cli markdown +create \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--folder-token` | 否 | 目标 Drive 文件夹 token省略时创建到根目录 |
| `--folder-token` | 否 | 目标 Drive 文件夹 token`--wiki-token` 互斥;省略时创建到根目录 |
| `--wiki-token` | 否 | 目标 wiki 节点 token`--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file``-`stdin |
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
@@ -51,8 +57,10 @@ lark-cli markdown +create \
## 关键约束
- `--content``--file` 必须二选一
- `--folder-token``--wiki-token` 互斥
- `--name` 必须带 `.md` 后缀
- `--file` 指向的本地文件名也必须带 `.md` 后缀
-`--wiki-token` 时,返回值中不会附带 `/file/<token>` URL因为 wiki 承载文件没有稳定的独立 file URL
## 返回值

View File

@@ -0,0 +1,156 @@
# markdown +diff
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
比较 Drive 中原生 Markdown 的两个历史版本,或比较远端 Markdown 与本地 `.md` 草稿。需要历史版本号时,先用 [`drive +version-history`](../../lark-drive/references/lark-drive-version-history.md) 获取 `version`,不要使用 `tag`
## 命令
```bash
# 比较两个远端版本
lark-cli markdown +diff \
--file-token boxcnxxxx \
--from-version 7633658129540910621 \
--to-version 7633658129540910628
# 比较历史版本与远端最新版本
lark-cli markdown +diff \
--file-token boxcnxxxx \
--from-version 7633658129540910621
# 比较远端最新版本与本地草稿
lark-cli markdown +diff \
--file-token boxcnxxxx \
--file ./draft.md \
--format pretty
# 比较指定远端版本与本地草稿
lark-cli markdown +diff \
--file-token boxcnxxxx \
--from-version 7633658129540910621 \
--file ./draft.md
# 预览底层请求
lark-cli markdown +diff \
--file-token boxcnxxxx \
--from-version 7633658129540910621 \
--to-version 7633658129540910628 \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | 目标 Markdown 文件 token |
| `--from-version` | 否 | 基准远端版本;不传 `--file` 时必填,传 `--file` 时省略表示“远端最新 vs 本地文件” |
| `--to-version` | 否 | 目标远端版本;要求同时传 `--from-version`,且不能与 `--file` 一起使用。省略时表示远端最新版本 |
| `--file` | 否 | 本地 `.md` 文件路径;传入后进入“远端 vs 本地”比较模式 |
| `--context-lines` | 否 | unified diff 每个 hunk 前后保留的上下文行数,默认 `3` |
| `--format` | 否 | 仅支持 `json`(默认)和 `pretty` |
## 关键行为
- `--file` 存在时:
- 省略 `--from-version` = 比较“远端最新版本 vs 本地文件”
- 传入 `--from-version` = 比较“指定远端版本 vs 本地文件”
- `--to-version` 只能用于“远端版本 vs 远端版本”,不能与 `--file` 同时出现
- `--format pretty` 输出带颜色的 unified diff`--format json` 返回结构化摘要和完整 diff 文本
- 无差异时:
- `json` 输出里 `changed=false`
- `pretty` 输出固定为 `No differences.`
## 返回值
```json
{
"ok": true,
"identity": "user",
"data": {
"changed": true,
"mode": "remote_vs_remote",
"file_token": "boxcnxxxx",
"from_version": "7633658129540910621",
"to_version": "7633658129540910628",
"from_label": "a/boxcnxxxx@version:7633658129540910621",
"to_label": "b/boxcnxxxx@version:7633658129540910628",
"added_lines": 3,
"deleted_lines": 2,
"context_lines": 3,
"hunks": [
{
"header": "@@ -1,6 +1,7 @@",
"old_start": 1,
"old_lines": 6,
"new_start": 1,
"new_lines": 7
}
],
"diff": "--- a/boxcnxxxx@version:7633658129540910621\n+++ b/boxcnxxxx@version:7633658129540910628\n@@ -1,2 +1,2 @@\n..."
}
}
```
完整字段说明:
| 字段 | 层级 | 含义 |
|------|------|------|
| `ok` | 顶层 | CLI 通用成功标记;`true` 表示命令执行成功 |
| `identity` | 顶层 | 本次执行使用的身份,通常是 `user``bot` |
| `data` | 顶层 | 本次 diff 的业务结果对象 |
| `changed` | `data` | 是否存在差异;`true` 表示两侧内容不同,`false` 表示完全一致 |
| `mode` | `data` | 比较模式;`remote_vs_remote` = 远端对远端,`remote_vs_local` = 远端对本地 |
| `file_token` | `data` | 被比较的远端 Markdown 文件 token |
| `from_version` | `data` | 基准远端版本号;远端最新 vs 本地时可能为空字符串 |
| `to_version` | `data` | 目标远端版本号;当目标侧是远端最新版本或本地文件时通常为空字符串 |
| `from_label` | `data` | unified diff 基准侧标签名,会直接出现在 `diff` 文本的 `---` 头部 |
| `to_label` | `data` | unified diff 目标侧标签名,会直接出现在 `diff` 文本的 `+++` 头部 |
| `added_lines` | `data` | 新增行数统计 |
| `deleted_lines` | `data` | 删除行数统计 |
| `context_lines` | `data` | 每个 hunk 前后保留的上下文行数,对应传入的 `--context-lines` |
| `hunks` | `data` | 结构化的变更块摘要数组;每个元素对应 patch 里的一个 `@@ ... @@` 段 |
| `diff` | `data` | 完整 unified diff 文本;最适合直接阅读或保存 |
| `local_file` | `data` | 仅在 `remote_vs_local` 模式下出现;值就是传给 `--file` 的本地 Markdown 路径 |
标签字段补充:
- `from_label` / `to_label` 只用于标识 diff 两侧,不代表额外 API 字段
- `from_label` 表示基准侧,`to_label` 表示目标侧
- 远端版本通常形如 `a/<file_token>@version:<version>``b/<file_token>@version:<version>`
- 当目标侧是远端最新版本时,`to_label` 形如 `b/<file_token>@latest`
- 当目标侧是本地文件时,`to_label` 形如 `b/./draft.md`
`hunks` 子字段说明:
| 字段 | 含义 |
|------|------|
| `header` | 原始 hunk 头,例如 `@@ -3,1 +3,1 @@` |
| `old_start` | 旧内容从第几行开始 |
| `old_lines` | 旧内容这段覆盖多少行 |
| `new_start` | 新内容从第几行开始 |
| `new_lines` | 新内容这段覆盖多少行 |
补充说明:
- `hunks` 适合 agent 或脚本快速定位变更范围;完整逐行内容仍以 `diff` 字段为准
- `changed=false` 时,`hunks` 通常为空数组,`diff` 通常为空字符串;如果使用 `--format pretty`,终端输出会是 `No differences.`
远端 vs 本地时会额外返回:
```json
{
"local_file": "./draft.md"
}
```
- `local_file`
- 只有传了 `--file`、进入“远端 vs 本地”模式时才会返回
- 值就是本次命令实际比较的本地 Markdown 路径,也就是你传给 `--file` 的那个路径
- 它表示“目标侧本地文件”,不是临时下载文件,也不是远端文件名
- 如果没有这个字段,说明本次是“远端版本 vs 远端版本”
## 参考
- [lark-markdown](../SKILL.md) — Markdown 域总览
- [lark-drive-version-history](../../lark-drive/references/lark-drive-version-history.md) — 获取可用于 diff 的历史版本号
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,7 +1,7 @@
---
name: lark-sheets
version: 1.2.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-doc 的 docs +search 先定位资源。"
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。"
metadata:
requires:
bins: ["lark-cli"]
@@ -13,8 +13,6 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 快速决策
- 按标题或关键词找云空间里的表格文件,先用 `lark-cli docs +search`
- `docs +search` 会直接返回 `SHEET` 结果,不要把它误解成只能搜文档 / Wiki。
- 已知 spreadsheet URL / token 后,再进入 `sheets +info``sheets +read``sheets +find` 等对象内部操作。
## 核心概念
@@ -188,7 +186,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-sheets-spreadsheet-management.md#create) | Create a spreadsheet (optional header row and initial data) |
| [`+info`](references/lark-sheets-spreadsheet-management.md#info) | View spreadsheet and sheet information |
| [`+info`](references/lark-sheets-spreadsheet-management.md#info) | View spreadsheet metadata and sheet information |
| [`+export`](references/lark-sheets-spreadsheet-management.md#export) | Export a spreadsheet (async task polling + optional download) |
### Sheet Management

View File

@@ -5,7 +5,7 @@
这份 reference 汇总电子表格对象级操作:
- `+create`:创建电子表格
- `+info`:查看电子表格和工作表信息
- `+info`:查看电子表格元信息和工作表列表
- `+export`:导出电子表格
<a id="create"></a>
@@ -60,8 +60,14 @@ lark-cli sheets +create --title "测试表" --dry-run
用于:
- 从表格 URL / token 获取 `spreadsheet_token`
- 获取电子表格标题、URL、所有者等元信息
- 列出工作表的 `sheet_id`、标题、行列数、冻结状态等信息
权限说明:
- 该 shortcut 声明了 `sheets:spreadsheet.meta:read``sheets:spreadsheet:read`,本地 scope preflight 要求两者同时满足
- `spreadsheet` 元信息来自 `spreadsheets/:token` 查询,工作表列表来自额外的 `spreadsheets/:token/sheets/query` 查询
```bash
# 传 URL支持 wiki URL
lark-cli sheets +info --url "https://example.larksuite.com/sheets/shtxxxxxxxx"

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBaseFormDetailDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-detail",
"--share-token", "shrXXXX",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/detail")
assert.Contains(t, output, `"share_token"`)
assert.Contains(t, output, "shrXXXX")
assert.Contains(t, output, `"method": "POST"`)
}
func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-detail",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "share-token")
}

View File

@@ -34,6 +34,8 @@ func TestDriveUploadDryRun_WikiTarget(t *testing.T) {
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, "parent_type")
assert.Contains(t, output, "parent_node")
assert.Contains(t, output, "wikcnDryRunUploadTarget")
@@ -61,6 +63,8 @@ func TestDriveUploadDryRun_WithFileToken(t *testing.T) {
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"parent_node": "fldDryRunUploadTarget"`)
assert.Contains(t, output, `"file_token": "boxcnDryRunOverwriteTarget"`)
}

View File

@@ -37,12 +37,43 @@ func TestMarkdownCreateDryRun_Content(t *testing.T) {
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"file_name": "README.md"`)
assert.Contains(t, output, `"parent_node": "fldcnMarkdownDryRun"`)
assert.Contains(t, output, `"parent_type": "explorer"`)
assert.Contains(t, output, `"size": 7`)
}
func TestMarkdownCreateDryRun_WikiTarget(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", "+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "wikcnMarkdownDryRun",
"--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/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"file_name": "README.md"`)
assert.Contains(t, output, `"parent_node": "wikcnMarkdownDryRun"`)
assert.Contains(t, output, `"parent_type": "wiki"`)
assert.Contains(t, output, `"size": 7`)
}
func TestMarkdownCreateDryRun_FileShowsConcreteSize(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
@@ -67,6 +98,8 @@ func TestMarkdownCreateDryRun_FileShowsConcreteSize(t *testing.T) {
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"file": "@note.md"`)
assert.Contains(t, output, `"size": 5`)
}
@@ -96,6 +129,83 @@ func TestMarkdownCreateDryRun_RejectsEmptyContent(t *testing.T) {
assert.Contains(t, errMsg, "empty markdown content is not supported")
}
func TestMarkdownDiffDryRun_RemoteVsRemote(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", "+diff",
"--file-token", "boxcnMarkdownDryRun",
"--from-version", "7633658129540910621",
"--to-version", "7633658129540910628",
"--context-lines", "1",
"--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, `"mode": "remote_vs_remote"`)
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"version": "7633658129540910628"`)
assert.Contains(t, output, `"context_lines": 1`)
}
func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(dir+"/draft.md", []byte("# draft\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+diff",
"--file-token", "boxcnMarkdownDryRun",
"--file", "./draft.md",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: dir,
})
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, `"mode": "remote_vs_local"`)
assert.Contains(t, output, `"local_file": "./draft.md"`)
}
func TestMarkdownCreateDryRun_RejectsEmptyWikiToken(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", "+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, result.Stdout+result.Stderr, "--wiki-token cannot be empty")
}
func TestMarkdownFetchDryRun_OutputFile(t *testing.T) {
setMarkdownDryRunConfigEnv(t)

View File

@@ -6,10 +6,12 @@ package markdown
import (
"context"
"os"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
@@ -126,4 +128,137 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) {
fetchUpdatedResult.AssertExitCode(t, 0)
fetchUpdatedResult.AssertStdoutStatus(t, true)
require.Equal(t, updatedContent, gjson.Get(fetchUpdatedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchUpdatedResult.Stdout)
historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-history",
"--file-token", fileToken,
},
DefaultAs: "user",
})
require.NoError(t, err)
historyResult.AssertExitCode(t, 0)
historyResult.AssertStdoutStatus(t, true)
latestVersion := gjson.Get(overwriteResult.Stdout, "data.version").String()
require.NotEmpty(t, latestVersion, "stdout:\n%s", overwriteResult.Stdout)
versions := gjson.Get(historyResult.Stdout, "data.versions").Array()
require.GreaterOrEqual(t, len(versions), 2, "stdout:\n%s", historyResult.Stdout)
var previousVersion string
// version-history returns versions in descending chronological order;
// pick the first non-latest as the previous version.
for _, version := range versions {
candidate := version.Get("version").String()
if candidate != "" && candidate != latestVersion {
previousVersion = candidate
break
}
}
require.NotEmpty(t, previousVersion, "stdout:\n%s", historyResult.Stdout)
diffResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+diff",
"--file-token", fileToken,
"--from-version", previousVersion,
"--to-version", latestVersion,
},
DefaultAs: "user",
})
require.NoError(t, err)
diffResult.AssertExitCode(t, 0)
diffResult.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(diffResult.Stdout, "data.changed").Bool(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, "remote_vs_remote", gjson.Get(diffResult.Stdout, "data.mode").String(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, previousVersion, gjson.Get(diffResult.Stdout, "data.from_version").String(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, latestVersion, gjson.Get(diffResult.Stdout, "data.to_version").String(), "stdout:\n%s", diffResult.Stdout)
assert.GreaterOrEqual(t, len(gjson.Get(diffResult.Stdout, "data.hunks").Array()), 1, "stdout:\n%s", diffResult.Stdout)
diffText := gjson.Get(diffResult.Stdout, "data.diff").String()
assert.True(t, strings.Contains(diffText, "-hello markdown workflow") || strings.Contains(diffText, "-# Initial"), "stdout:\n%s", diffResult.Stdout)
assert.True(t, strings.Contains(diffText, "+new body") || strings.Contains(diffText, "+# Updated"), "stdout:\n%s", diffResult.Stdout)
}
func TestMarkdownCreateWorkflow_WikiParent(t *testing.T) {
if os.Getenv("LARK_MARKDOWN_E2E") == "" {
t.Skip("set LARK_MARKDOWN_E2E=1 to run markdown live workflow after backend version support is deployed")
}
wikiToken := strings.TrimSpace(os.Getenv("LARK_MARKDOWN_E2E_WIKI_TOKEN"))
if wikiToken == "" {
t.Skip("set LARK_MARKDOWN_E2E_WIKI_TOKEN to run markdown live workflow against a wiki parent node")
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-markdown-wiki-" + suffix + ".md"
initialContent := "# Wiki Parent\n\nhello wiki markdown workflow\n"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--wiki-token", wikiToken,
"--name", fileName,
"--content", initialContent,
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
require.NotEmpty(t, gjson.Get(createResult.Stdout, "data.url").String(), "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
requireDeleteWikiHostedMarkdownFile(parentT, fileToken)
})
fetchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "bot",
})
require.NoError(t, err)
fetchResult.AssertExitCode(t, 0)
fetchResult.AssertStdoutStatus(t, true)
require.Equal(t, initialContent, gjson.Get(fetchResult.Stdout, "data.content").String(), "stdout:\n%s", fetchResult.Stdout)
}
func requireDeleteWikiHostedMarkdownFile(parentT *testing.T, fileToken string) {
parentT.Helper()
request := clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
}
for _, identity := range []string{"bot", "user"} {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: request.Args,
DefaultAs: identity,
})
cleanupCancel()
if err == nil && result != nil && result.ExitCode == 0 {
return
}
}
parentT.Fatalf("cleanup failed: could not delete wiki-hosted markdown file %s with either bot or user identity", fileToken)
}