Compare commits

...

14 Commits

Author SHA1 Message Date
sunpeiyang.996
73ea222ba7 feat: inject Doubao docs scene
Change-Id: Id06f24174c8f4922cc1e029cd82828fbc72e89b4
2026-05-09 16:34:06 +08:00
yangjunzhou
d8e08736f1 feat: request thread roots for chat message list
Change-Id: I3901b27e70b0e4db506ff199eb03c96fcf98671d
2026-04-23 21:42:24 +08:00
tuxedomm
138a2ef785 test: cover flag-completion gate and shortcut enum/format registration
- Extract configureFlagCompletions helper in cmd/root.go so the gate
  between normal invocations and __complete requests can be unit-tested.
- Add TestConfigureFlagCompletions with table cases for plain commands,
  --help, __complete, and the completion subcommand.
- Add TestShortcutMount_FlagCompletions{Registered,Disabled} in
  shortcuts/common to exercise the two RegisterFlagCompletion call
  sites in registerShortcutFlagsWithContext (enum flag and --format).

Change-Id: Idcb302bb40045b1c5b34580ec90d586eae8b8707
2026-04-22 15:02:00 +08:00
tuxedomm
0cb6cdf818 fix: skip flag-completion registration outside completion path
Cobra keeps completion callbacks in a package-global map keyed by
*pflag.Flag with no removal path, so registrations made during Build()
outlive the command itself. Route all seven call sites through
cmdutil.RegisterFlagCompletion and enable registration only when the
invocation actually serves a __complete request.

Measured over 30 dropped Builds: ~202 KB / 2180 retained objects per
Build before, ~0 after.

Change-Id: I734d598a4c91a92c33b02e0f292f640cc0e224c6
2026-04-22 15:02:00 +08:00
chenjinxiong.03
5d9b3d305f fix(output): recognise typed slices in array field detection
ExtractItems / FindArrayField only matched []interface{}, so shortcuts
that collected results into []map[string]interface{} fell through to
the key/value envelope render under --format table/csv/ndjson. Accept
any reflect.Slice via a new asGenericSlice helper, and register the
chats/messages/tasks/created_tasks known fields. Adds regression tests
covering typed map slices, typed struct slices, and raw typed slices.

Change-Id: I4562645bfeb9bb45e11273f491eb1ab0080118fe
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:10:24 +08:00
sunpeiyang.996
9229c50fcf feat(doc): add v2 API support for +create/+fetch/+update
Introduce an OpenAPI-backed v2 path for docs shortcuts alongside the
existing MCP-backed v1 path, selectable via --api-version v1|v2. v1
stays default and prints a deprecation notice; v2 is auto-detected
when v2-only flags are present.

v2 highlights:
- +create / +update take --content (XML or Markdown via --doc-format);
  +update adds structured --command ops (str_replace, block_*, append,
  overwrite, ...).
- +fetch adds --detail (simple/with-ids/full), partial read --scope
  (outline/range/keyword/section) with context + max-depth controls.
- XML output is no longer HTML-escaped (OutRaw / OutFormatRaw).
- Versioned --help hides flags that don't apply to the selected API
  version via a new Shortcut.PostMount hook.

Docs/skills updated to v2 usage; block-id based comments replace
--selection-with-ellipsis in drive +add-comment references.

Change-Id: I4b3c35fe967920f9e2546d5b6ca0ec7dcfd415fc
2026-04-21 10:41:33 +08:00
chenjinxiong.03
d25f79bb64 docs(rebase-420): add conflict resolution report for dd05477
Change-Id: I25a8d1ce7f731cb3514be4b4106326be82700720
2026-04-20 11:29:24 +08:00
tuxedomm
4d84994ce6 feat: add SetDefaultFS to allow replacing the global filesystem implementation
Change-Id: If5c3e50e84859f9ac4ffceeb0ac3dc7b7330b274
2026-04-20 01:06:20 +08:00
tuxedomm
6b56e0fdde feat(schema): filter methods by strict mode in schema output
When strict mode is active, schema output now excludes methods that
are incompatible with the forced identity. This applies to both
pretty and JSON output formats at the resource and method levels.

Change-Id: I39647d5578466c3e23dc545bfb917ae075203ad7
2026-04-20 00:44:59 +08:00
caojie0621
1262aac480 fix(sheets): normalize single-cell range in +set-style and +batch-set-style (#548)
/style and /styles_batch_update require full "A1:A1" form and reject
single-cell shorthand "A1". +set-style was using normalizeSheetRange
(prefix-only) and +batch-set-style passed --data through unchanged,
so both failed with `wrong range` when callers supplied a single cell.

Switch +set-style to normalizePointRange, and walk each ranges[]
entry in +batch-set-style through normalizePointRange before sending.
Multi-cell spans pass through unchanged.
2026-04-18 23:29:14 +08:00
caojie0621
abb02cd46c feat(sheets): add float image shortcuts (#494)
Implement +create-float-image, +update-float-image, +get-float-image,
+list-float-images, and +delete-float-image shortcuts wrapping the v3
spreadsheet float_image API. The create reference doc includes the
prerequisite media upload step with the correct parent_type
(sheet_image) to avoid common token mismatch errors.
2026-04-18 23:27:11 +08:00
haozhenghua-code
db7d3cb64d fix(im): cap basic_batch user_ids at 10 per API limit (#551)
The POST /contact/v3/users/basic_batch endpoint caps user_ids at 1~10
per request, but batchResolveByBasicContact was chunking by 50. When
user identity needed to resolve >10 unresolved sender names, the
single oversized request was rejected, causing the batch resolver to
bail out and leave sender names empty for the rest.

Lower batchSize to 10 and add a unit test that exercises 25 missing
IDs and asserts they are sent as 10 / 10 / 5.
2026-04-18 18:41:30 +08:00
Paulazaaza-dev
5134719da9 feat: add remind/initiated method (#554)
Change-Id: I27c00d96a9478efbf39fbc1118bb6bcb75fe6b14
2026-04-18 17:46:23 +08:00
zkh-bytedance
5a0e1d3dd9 fix(whiteboard): Deprecate old lark-whiteboard-cli skill (#547) 2026-04-18 00:36:56 +08:00
79 changed files with 4868 additions and 1484 deletions

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -201,7 +201,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
lark-cli docs +create --doc-format markdown --content "<title>Weekly Report</title>\n# Progress\n- Completed feature X"
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -202,7 +202,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
lark-cli docs +create --doc-format markdown --content "<title>周报</title>\n# 本周进展\n- 完成了 X 功能"
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -96,10 +96,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})

View File

@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
})

115
cmd/build.go Normal file
View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"os"
"golang.org/x/term"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
// BuildOption configures optional aspects of the command tree construction.
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
}
// WithIO sets the IO streams for the CLI. If not provided, os.Stdin/Stdout/Stderr are used.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
return func(c *buildConfig) {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
c.streams = &cmdutil.IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
}
}
// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used.
func WithKeychain(kc keychain.KeychainAccess) BuildOption {
return func(c *buildConfig) {
c.keychain = kc
}
}
// Build constructs the full command tree without executing.
// Returns only the cobra.Command; Factory is internal.
// Use Execute for the standard production entry point.
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
_, rootCmd := buildInternal(ctx, inv, opts...)
return rootCmd
}
// buildInternal is the internal constructor that also returns Factory for error handling.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
cfg := &buildConfig{
streams: cmdutil.SystemIO(),
}
for _, o := range opts {
o(cfg)
}
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
globals := &GlobalOptions{Profile: inv.Profile}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
rootCmd.SetContext(ctx)
rootCmd.SetIn(cfg.streams.In)
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
return f, rootCmd
}

18
cmd/init.go Normal file
View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import "github.com/larksuite/cli/internal/vfs"
// SetDefaultFS replaces the global filesystem implementation used by internal
// packages. The provided fs must implement the vfs.FS interface. If fs is nil,
// the default OS filesystem is restored.
//
// Call this before Build or Execute to take effect.
func SetDefaultFS(fs vfs.FS) {
if fs == nil {
fs = vfs.OsFs{}
}
vfs.DefaultFS = fs
}

View File

@@ -14,15 +14,6 @@ import (
"os"
"strconv"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
@@ -30,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -95,38 +85,9 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
f := cmdutil.NewDefault(inv)
configureFlagCompletions(os.Args)
globals := &GlobalOptions{Profile: inv.Profile}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
f, rootCmd := buildInternal(context.Background(), inv)
// --- Update check (non-blocking) ---
if !isCompletionCommand(os.Args) {
@@ -190,6 +151,12 @@ func isCompletionCommand(args []string) bool {
return false
}
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
// the invocation will actually serve a __complete request.
func configureFlagCompletions(args []string) {
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
}
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
func handleRootError(f *cmdutil.Factory, err error) int {

View File

@@ -135,7 +135,7 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
f := cmdutil.NewDefault(nil, cmdutil.InvocationContext{Profile: profile})
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}

View File

@@ -196,3 +196,28 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}
func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
tests := []struct {
name string
args []string
wantDisabled bool
}{
{"plain command", []string{"im", "+send"}, true},
{"help flag", []string{"im", "--help"}, true},
{"no args", []string{}, true},
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
{"completion subcommand", []string{"completion", "bash"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
configureFlagCompletions(tc.args)
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
}
})
}
}

View File

@@ -4,12 +4,14 @@
package schema
import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
@@ -19,6 +21,7 @@ import (
// SchemaOptions holds all inputs for the schema command.
type SchemaOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
// Positional args
Path string
@@ -41,7 +44,7 @@ func printServices(w io.Writer) {
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}) {
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
@@ -55,9 +58,13 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
if len(methods) == 0 {
continue
}
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
@@ -359,6 +366,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
if len(args) > 0 {
opts.Path = args[0]
}
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
@@ -369,7 +377,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
cmd.ValidArgsFunction = completeSchemaPath
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -451,6 +459,7 @@ func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]s
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
if opts.Path == "" {
printServices(out)
@@ -469,9 +478,9 @@ func schemaRun(opts *SchemaOptions) error {
if len(parts) == 1 {
if opts.Format == "pretty" {
printResourceList(out, spec)
printResourceList(out, spec, mode)
} else {
output.PrintJson(out, spec)
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
}
return nil
}
@@ -492,6 +501,7 @@ func schemaRun(opts *SchemaOptions) error {
if opts.Format == "pretty" {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
@@ -500,13 +510,26 @@ func schemaRun(opts *SchemaOptions) error {
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
} else {
output.PrintJson(out, resource)
// For JSON output, filter methods in a copy to avoid mutating the registry.
if mode.IsActive() {
filtered := make(map[string]interface{})
for k, v := range resource {
filtered[k] = v
}
if methods, ok := resource["methods"].(map[string]interface{}); ok {
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
}
output.PrintJson(out, filtered)
} else {
output.PrintJson(out, resource)
}
}
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var mNames []string
@@ -525,3 +548,67 @@ func schemaRun(opts *SchemaOptions) error {
}
return nil
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() {
return spec
}
result := make(map[string]interface{}, len(spec))
for k, v := range spec {
result[k] = v
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return result
}
filteredRes := make(map[string]interface{}, len(resources))
for resName, resVal := range resources {
resMap, ok := resVal.(map[string]interface{})
if !ok {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
filtered := filterMethodsByStrictMode(methods, mode)
if len(filtered) == 0 {
continue
}
resCopy := make(map[string]interface{}, len(resMap))
for k, v := range resMap {
resCopy[k] = v
}
resCopy["methods"] = filtered
filteredRes[resName] = resCopy
}
result["resources"] = filteredRes
return result
}
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
// Returns the original map unmodified when strict mode is off.
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() || methods == nil {
return methods
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
filtered := make(map[string]interface{}, len(methods))
for name, val := range methods {
m, ok := val.(map[string]interface{})
if !ok {
continue
}
tokens, _ := m["accessTokens"].([]interface{})
if tokens == nil {
filtered[name] = val
continue
}
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == token {
filtered[name] = val
break
}
}
}
return filtered
}

View File

@@ -177,11 +177,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
}
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"sync/atomic"
"github.com/spf13/cobra"
)
// Cobra keeps completion callbacks in a package-global map keyed by
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
// outlive the command itself. Skip registration when the current invocation
// will not serve a completion request.
var flagCompletionsDisabled atomic.Bool
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
// registering and no-op. Typically set once at process start.
func SetFlagCompletionsDisabled(disabled bool) {
flagCompletionsDisabled.Store(disabled)
}
// FlagCompletionsDisabled reports the current switch state.
func FlagCompletionsDisabled() bool {
return flagCompletionsDisabled.Load()
}
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
// and honors the package switch. The underlying error is swallowed to match
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
if flagCompletionsDisabled.Load() {
return
}
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/spf13/cobra"
)
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
if FlagCompletionsDisabled() {
t.Fatal("expected default false")
}
SetFlagCompletionsDisabled(true)
if !FlagCompletionsDisabled() {
t.Fatal("expected true after Set(true)")
}
SetFlagCompletionsDisabled(false)
if FlagCompletionsDisabled() {
t.Fatal("expected false after Set(false)")
}
}
// When disabled, a *cobra.Command must be collectable after the caller drops
// its reference — i.e. the wrapper did not touch cobra's global map.
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
SetFlagCompletionsDisabled(true)
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
const N = 5
var collected atomic.Int32
func() {
for range N {
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
})
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
}
}()
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
for range 30 {
runtime.GC()
time.Sleep(20 * time.Millisecond)
}
if got := collected.Load(); int(got) != N {
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
}
}
// When enabled, the registered completion must be reachable via cobra.
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
SetFlagCompletionsDisabled(false)
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
want := []cobra.Completion{"a", "b"}
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return want, cobra.ShellCompDirectiveNoFileComp
})
fn, ok := cmd.GetFlagCompletionFunc("foo")
if !ok {
t.Fatal("expected completion func to be registered")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected completion result: %v", got)
}
}

View File

@@ -8,13 +8,11 @@ import (
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"golang.org/x/term"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
@@ -34,27 +32,26 @@ import (
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
func NewDefault(inv InvocationContext) *Factory {
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
if streams == nil {
streams = SystemIO()
}
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
}
f.IOStreams = &IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
IOStreams: streams,
}
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc()
f.HttpClient = cachedHttpClientFunc(f)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: f.Keychain,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
@@ -93,9 +90,9 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
return nil
}
func cachedHttpClientFunc() func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
util.WarnIfProxied(os.Stderr)
util.WarnIfProxied(f.IOStreams.ErrOut)
var transport http.RoundTripper = util.NewBaseTransport()
transport = &RetryTransport{Base: transport}
@@ -122,7 +119,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
util.WarnIfProxied(os.Stderr)
util.WarnIfProxied(f.IOStreams.ErrOut)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
@@ -142,7 +139,7 @@ func buildSDKTransport() http.RoundTripper {
}
type credentialDeps struct {
Keychain keychain.KeychainAccess
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer

View File

@@ -63,7 +63,7 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(InvocationContext{Profile: "target"})
f := NewDefault(nil, InvocationContext{Profile: "target"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
}
@@ -103,7 +103,7 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(InvocationContext{Profile: "missing"})
f := NewDefault(nil, InvocationContext{Profile: "missing"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
}
@@ -144,7 +144,7 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
f := NewDefault(nil, InvocationContext{})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
@@ -164,7 +164,7 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T)
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
f := NewDefault(nil, InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -189,7 +189,7 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
f := NewDefault(nil, InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -217,7 +217,7 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
fileio.Register(provider)
t.Cleanup(func() { fileio.Register(prev) })
f := NewDefault(InvocationContext{})
f := NewDefault(nil, InvocationContext{})
if f.FileIOProvider != provider {
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
}

View File

@@ -4,11 +4,12 @@
package cmdutil
import (
"io"
"testing"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc()
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c1, err := fn()
if err != nil {
@@ -28,7 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc()
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -36,7 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc()
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/extension/fileio"
@@ -122,9 +123,22 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
// Add top-level JSON keys as text form fields.
if m, ok := dataJSON.(map[string]any); ok {
for k, v := range m {
fd.AddField(k, fmt.Sprintf("%v", v))
fd.AddField(k, formatFormFieldValue(v))
}
}
return fd, nil
}
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
// field string. float64 is handled specially: fmt's default %v/%g switches to
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
// backends reject when parsing the field as an integer. Use decimal notation
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
// All other types fall through to %v.
func formatFormFieldValue(v any) string {
if n, ok := v.(float64); ok {
return strconv.FormatFloat(n, 'f', -1, 64)
}
return fmt.Sprintf("%v", v)
}

View File

@@ -336,3 +336,40 @@ func TestBuildFormdata(t *testing.T) {
}
})
}
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
// float64 delegates to %g which switches to scientific notation at ~1e6
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
// integer reject that, surfacing as a generic "params error".
func TestFormatFormFieldValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in any
want string
}{
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
{"float64 below scientific threshold", float64(358934), "358934"},
{"float64 zero", float64(0), "0"},
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
{"float64 negative", float64(-42), "-42"},
{"float64 fractional preserved", float64(3.14), "3.14"},
{"string pass-through", "hello", "hello"},
{"bool true", true, "true"},
{"int via %v", 42, "42"},
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatFormFieldValue(tt.in)
if got != tt.want {
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

View File

@@ -3,7 +3,12 @@
package cmdutil
import "io"
import (
"io"
"os"
"golang.org/x/term"
)
// IOStreams provides the standard input/output/error streams.
// Commands should use these instead of os.Stdin/Stdout/Stderr
@@ -14,3 +19,13 @@ type IOStreams struct {
ErrOut io.Writer
IsTerminal bool
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
func SystemIO() *IOStreams {
return &IOStreams{
In: os.Stdin, //nolint:forbidigo // entry point for real stdio
Out: os.Stdout, //nolint:forbidigo // entry point for real stdio
ErrOut: os.Stderr, //nolint:forbidigo // entry point for real stdio
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), //nolint:forbidigo // need Fd() for terminal check
}
}

View File

@@ -21,11 +21,14 @@ import (
// DefaultAccountProvider resolves account from config.json via keychain.
type DefaultAccountProvider struct {
keychain keychain.KeychainAccess
keychain func() keychain.KeychainAccess
profile string
}
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
if kc == nil {
kc = keychain.Default
}
return &DefaultAccountProvider{keychain: kc, profile: profile}
}
@@ -36,7 +39,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
if err != nil {
return nil, err
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/keychain"
)
type noopKC struct{}
@@ -99,7 +100,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
}
ep := &envprovider.Provider{}
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
)
@@ -15,6 +16,29 @@ import (
var knownArrayFields = []string{
"items", "files", "events", "rooms", "records", "nodes",
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
"chats", "messages", "tasks", "created_tasks",
}
// asGenericSlice converts any slice value into []interface{}.
// Returns the slice and true when v is a slice, regardless of element type
// ([]interface{}, []map[string]interface{}, []MyStruct, etc.). This keeps
// formatter logic working when business code uses typed slices.
func asGenericSlice(v interface{}) ([]interface{}, bool) {
if v == nil {
return nil, false
}
if s, ok := v.([]interface{}); ok {
return s, true
}
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice {
return nil, false
}
out := make([]interface{}, rv.Len())
for i := 0; i < rv.Len(); i++ {
out[i] = rv.Index(i).Interface()
}
return out, true
}
// FindArrayField finds the primary array field in a response's data object.
@@ -23,7 +47,7 @@ var knownArrayFields = []string{
func FindArrayField(data map[string]interface{}) string {
for _, name := range knownArrayFields {
if arr, ok := data[name]; ok {
if _, isArr := arr.([]interface{}); isArr {
if _, isArr := asGenericSlice(arr); isArr {
return name
}
}
@@ -31,7 +55,7 @@ func FindArrayField(data map[string]interface{}) string {
// Fallback: lexicographically first array field (deterministic)
var candidates []string
for k, v := range data {
if _, isArr := v.([]interface{}); isArr {
if _, isArr := asGenericSlice(v); isArr {
candidates = append(candidates, k)
}
}
@@ -68,11 +92,12 @@ func toGeneric(v interface{}) interface{} {
// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}})
// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5})
//
// If data is already a plain []interface{}, it is returned as-is.
// If data is already a slice, it is returned as a []interface{}. Typed slices
// such as []map[string]interface{} are also accepted via asGenericSlice.
func ExtractItems(data interface{}) []interface{} {
resultMap, ok := data.(map[string]interface{})
if !ok {
if arr, ok := data.([]interface{}); ok {
if arr, ok := asGenericSlice(data); ok {
return arr
}
return nil
@@ -81,7 +106,7 @@ func ExtractItems(data interface{}) []interface{} {
// Strategy 1: Lark API envelope — result["data"][arrayField]
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
if field := FindArrayField(dataObj); field != "" {
if items, ok := dataObj[field].([]interface{}); ok {
if items, ok := asGenericSlice(dataObj[field]); ok {
return items
}
}
@@ -90,7 +115,7 @@ func ExtractItems(data interface{}) []interface{} {
// Strategy 2: direct map — result[arrayField]
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
if field := FindArrayField(resultMap); field != "" {
if items, ok := resultMap[field].([]interface{}); ok {
if items, ok := asGenericSlice(resultMap[field]); ok {
return items
}
}

View File

@@ -266,6 +266,113 @@ func TestExtractItems(t *testing.T) {
}
}
// Regression: shortcuts often collect results into typed slices like
// []map[string]interface{} instead of []interface{}. ExtractItems must
// recognise those so --format table/csv/ndjson render the array rather
// than falling back to a key/value view of the envelope.
func TestExtractItems_TypedSlice(t *testing.T) {
cases := []struct {
name string
data interface{}
want int
}{
{
name: "direct map with []map[string]interface{} under known field",
data: map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_a", "name": "Alice"},
{"chat_id": "oc_b", "name": "Bob"},
},
"has_more": true,
"total": float64(2),
},
want: 2,
},
{
name: "envelope with []map[string]interface{} under data.messages",
data: map[string]interface{}{
"data": map[string]interface{}{
"messages": []map[string]interface{}{
{"message_id": "om_1"},
},
},
},
want: 1,
},
{
name: "direct map with []map[string]interface{} under created_tasks",
data: map[string]interface{}{
"created_tasks": []map[string]interface{}{
{"task_id": "t1"},
{"task_id": "t2"},
{"task_id": "t3"},
},
},
want: 3,
},
{
name: "typed slice of structs via fallback",
data: map[string]interface{}{
"widgets": []struct {
Name string `json:"name"`
}{{Name: "x"}, {Name: "y"}},
},
want: 2,
},
{
name: "raw typed slice passed directly",
data: []map[string]interface{}{
{"k": "v"},
},
want: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
items := ExtractItems(tc.data)
if len(items) != tc.want {
t.Fatalf("expected %d items, got %d (%v)", tc.want, len(items), items)
}
})
}
}
// Regression: --format table on the 7 affected shortcuts used to print
// the envelope as a key/value table because the typed slice was ignored.
// After the fix, the array should be expanded into a proper header row.
func TestFormatValue_Table_TypedSlice(t *testing.T) {
data := map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_abc", "name": "Lark test"},
},
"has_more": true,
"total": float64(1),
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatTable)
out := buf.String()
if !strings.Contains(out, "chat_id") {
t.Errorf("table output should expose chat_id column, got:\n%s", out)
}
if !strings.Contains(out, "oc_abc") {
t.Errorf("table output should contain the chat row, got:\n%s", out)
}
// The fallback bug manifested as the envelope being rendered as rows:
// the 'has_more' / 'total' envelope keys would appear as first-column
// labels. A correct render puts the array's element keys in the header
// and keeps envelope metadata out of the table body.
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "has_more") || strings.HasPrefix(trimmed, "total ") {
t.Errorf("envelope field leaked into table body:\n%s", out)
}
}
}
func TestFormatValue_LegacyFormats(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{

View File

@@ -38,6 +38,9 @@ const (
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -73,6 +76,12 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
// sheets-specific constraints that benefit from actionable hints
case LarkErrSheetsFloatImageInvalidDims:
return ExitAPI, "invalid_params",
"check --width / --height / --offset-x / --offset-y: " +
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
}
return ExitAPI, "api_error", ""

View File

@@ -40,6 +40,13 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
wantType: "cross_brand",
wantHint: "same brand environment",
},
{
name: "sheets float image invalid dims",
code: LarkErrSheetsFloatImageInvalidDims,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantHint: "--width / --height / --offset-x / --offset-y",
},
}
for _, tt := range tests {

70
rebase-420/dd05477.md Normal file
View File

@@ -0,0 +1,70 @@
# Cherry-pick 冲突解决报告: `dd05477`
- **原始 commit**: `dd05477` feat: add SetDefaultFS to allow replacing the global filesystem implementation
- **作者**: tuxedomm, 2026-04-09
- **新 commit**: `4d84994`
- **目标分支**: `feat/main_rebased_420`(基于 `larksuite/cli` 最新 main
## 改动范围
10 个文件, +179 / -70:
- **新增**: `cmd/build.go`, `cmd/init.go`
- **修改**: `cmd/root.go`, `cmd/root_integration_test.go`, `internal/cmdutil/factory_default.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`, `internal/credential/integration_test.go`
核心意图:
-`cmd.Execute()` 里的 root 命令组装逻辑抽取到新文件 `cmd/build.go``buildInternal()`, 并暴露 `Build()` 作为库入口
- 引入 `cmd/init.go` 里的 `SetDefaultFS(fs vfs.FS)` 允许调用方在 `Build/Execute` 之前替换全局 fs
- `cmdutil.NewDefault(inv)` 签名调整为 `NewDefault(streams *IOStreams, inv InvocationContext)`
- `credentialDeps.Keychain``keychain.KeychainAccess` 改为 `func() keychain.KeychainAccess`(惰性读取, 允许构造后替换)
- `cmdutil.SystemIO()` 新函数封装对真实 stdio 的引用
## 冲突情况
只有一个文件冲突: `cmd/root.go`2 处)
| 位置 | HEAD(main) | fork(dd05477) |
|---|---|---|
| imports 段 | 保留 `cmd/api`, `cmd/auth`, `cmd/completion`, `cmdconfig`, `cmd/doctor`, `cmd/profile`, `cmd/schema`, `cmd/service`, `cmdupdate`, `shortcuts` 等 | 全部删除(这些 import 随 Execute 函数体一起搬去新文件 `cmd/build.go`|
| `Execute()` 函数体 | 完整包含 Factory 构造 + rootCmd 构造 + 子命令注册 + strict-mode 剪枝 | 精简为 `f, rootCmd := buildInternal(context.Background(), inv)` |
### 为什么会冲突
fork 的 dd05477 比 fork 之前落后 main 很多 commit, 而 main 上(比如 PR #391)在 fork 不知道的情况下加了 `rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))` 这一行 —— 它处于 fork 想整体搬走的那段代码里。git 无法自动判断这一行应该保留还是跟着搬, 所以报冲突。
## 解决方案
**两处冲突都采用 fork 的重构结构**(把 imports / 组装逻辑搬去 `cmd/build.go`, 但在 `cmd/build.go``buildInternal()` 里**追加**了 main 新增的 update 命令。
### 具体改动
`cmd/build.go` 里:
```go
// imports 段补上
cmdupdate "github.com/larksuite/cli/cmd/update"
// 在 rootCmd.AddCommand(completion.NewCmdCompletion(f)) 之后追加
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
```
如果不这样做, 就会丢失 main PR #391 引入的 `lark-cli update` 子命令。
## 非冲突文件处理
其余 9 个文件的 patch 全部直接应用, 无语义冲突:
- `cmd/build.go`, `cmd/init.go`: 新增文件
- `cmd/root_integration_test.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/credential/integration_test.go`: 跟随签名变更调整调用方(`NewDefault(nil, ...)``cachedHttpClientFunc(&Factory{...})` 等)
- `internal/cmdutil/factory_default.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`: 签名/结构体字段类型调整
- `cmd/root.go`: 冲突段外其余部分update 检查、错误处理等)保持原样
## 验证
- `go build ./...` 通过
- `go test ./cmd/... ./internal/cmdutil/... ./internal/credential/...` 全部通过
## 依赖
- `internal/vfs` 包(`DefaultFS``OsFs``FS` interface在 main 上已存在, `SetDefaultFS` 要切换的全局状态有完整基础
- `cmdupdate`main PR #391)已存在

View File

@@ -488,12 +488,46 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
fmt.Fprintln(ctx.IO().Out, string(b))
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
if ctx.JqExpr != "" {
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
if ctx.outputErr == nil {
ctx.outputErr = err
}
}
return
}
enc := json.NewEncoder(ctx.IO().Out)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
_ = enc.Encode(env)
}
// OutFormat prints output based on --format flag.
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
// When JqExpr is set, routes through Out() regardless of format.
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.outFormat(data, meta, prettyFn, false)
}
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
// Use this when the data contains XML/HTML content that should be preserved as-is.
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.outFormat(data, meta, prettyFn, true)
}
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
outFn := ctx.Out
if raw {
outFn = ctx.OutRaw
}
if ctx.JqExpr != "" {
ctx.Out(data, meta)
outFn(data, meta)
return
}
switch ctx.Format {
@@ -501,10 +535,10 @@ func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, pretty
if prettyFn != nil {
prettyFn(ctx.IO().Out)
} else {
ctx.Out(data, meta)
outFn(data, meta)
}
case "json", "":
ctx.Out(data, meta)
outFn(data, meta)
default:
// table, csv, ndjson — pass data directly; FormatValue handles both
// plain arrays and maps with array fields (e.g. {"members":[…]})
@@ -595,6 +629,9 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
registerShortcutFlags(cmd, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
parent.AddCommand(cmd)
if shortcut.PostMount != nil {
shortcut.PostMount(cmd)
}
}
// runShortcut is the execution pipeline for a declarative shortcut.
@@ -860,7 +897,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
}
if len(fl.Enum) > 0 {
vals := fl.Enum
_ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return vals, cobra.ShellCompDirectiveNoFileComp
})
}
@@ -876,11 +913,11 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
})
if s.HasFormat {
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// TestShortcutMount_FlagCompletionsRegistered exercises the two
// cmdutil.RegisterFlagCompletion call sites in registerShortcutFlagsWithContext:
// the per-flag enum completion (runner.go:879) and the auto-injected --format
// completion (runner.go:895).
func TestShortcutMount_FlagCompletionsRegistered(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
cmdutil.SetFlagCompletionsDisabled(false)
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+fetch",
Description: "fetch doc",
HasFormat: true,
Flags: []Flag{
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+fetch"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// Enum flag completion.
fn, ok := cmd.GetFlagCompletionFunc("sort-by")
if !ok {
t.Fatal("expected completion func for --sort-by")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "asc" || got[1] != "desc" {
t.Fatalf("sort-by completion = %v, want [asc desc]", got)
}
// HasFormat-injected --format completion.
fn, ok = cmd.GetFlagCompletionFunc("format")
if !ok {
t.Fatal("expected completion func for --format")
}
got, _ = fn(cmd, nil, "")
want := []string{"json", "pretty", "table", "ndjson", "csv"}
if len(got) != len(want) {
t.Fatalf("format completion = %v, want %v", got, want)
}
for i, v := range want {
if got[i] != v {
t.Fatalf("format completion[%d] = %q, want %q", i, got[i], v)
}
}
}
// TestShortcutMount_FlagCompletionsDisabled verifies the switch actually
// prevents the two registrations from landing in cobra's global map.
func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
cmdutil.SetFlagCompletionsDisabled(true)
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+fetch",
Description: "fetch doc",
HasFormat: true,
Flags: []Flag{
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+fetch"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if _, ok := cmd.GetFlagCompletionFunc("sort-by"); ok {
t.Fatal("did not expect completion func for --sort-by when disabled")
}
if _, ok := cmd.GetFlagCompletionFunc("format"); ok {
t.Fatal("did not expect completion func for --format when disabled")
}
}

View File

@@ -3,7 +3,11 @@
package common
import "context"
import (
"context"
"github.com/spf13/cobra"
)
// Flag.Input source constants.
const (
@@ -43,6 +47,11 @@ type Shortcut struct {
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// PostMount is an optional hook called after the cobra.Command is fully
// configured (flags registered, tips set) but before it is added to the
// parent. Use it to install custom help functions or tweak the command.
PostMount func(cmd *cobra.Command)
}
// ScopesForIdentity returns the scopes applicable for the given identity.

View File

@@ -7,9 +7,35 @@ import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
func v1CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title", Hidden: true},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
}
}
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Create(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("content") != "" ||
runtime.Str("parent-token") != "" ||
runtime.Str("parent-position") != ""
}
var DocsCreate = common.Shortcut{
Service: "docs",
Command: "+create",
@@ -17,56 +43,85 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
Flags: []common.Flag{
{Name: "title", Desc: "document title"},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token"},
{Name: "wiki-node", Desc: "wiki node token"},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
},
v1CreateFlags(),
v2CreateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
count := 0
if runtime.Str("folder-token") != "" {
count++
if useV2Create(runtime) {
return validateCreateV2(ctx, runtime)
}
if runtime.Str("wiki-node") != "" {
count++
}
if runtime.Str("wiki-space") != "" {
count++
}
if count > 1 {
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
}
return nil
return validateCreateV1(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildDocsCreateArgs(runtime)
d := common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
if useV2Create(runtime) {
return dryRunCreateV2(ctx, runtime)
}
return d
return dryRunCreateV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := buildDocsCreateArgs(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
if useV2Create(runtime) {
return executeCreateV2(ctx, runtime)
}
augmentDocsCreateResult(runtime, result)
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
return executeCreateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
},
}
func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} {
// ── V1 (MCP) implementation ──
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("markdown") == "" {
return common.FlagErrorf("--markdown is required")
}
count := 0
if runtime.Str("folder-token") != "" {
count++
}
if runtime.Str("wiki-node") != "" {
count++
}
if runtime.Str("wiki-space") != "" {
count++
}
if count > 1 {
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
}
return nil
}
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildCreateArgsV1(runtime)
d := common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
}
augmentCreateResultV1(runtime, result)
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
}
@@ -90,18 +145,17 @@ type docsPermissionTarget struct {
Type string
}
func augmentDocsCreateResult(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectDocsPermissionTarget(result)
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectPermissionTarget(result)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
}
func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parseDocsPermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
return ref
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID != "" {
return docsPermissionTarget{Token: docID, Type: "docx"}
@@ -109,16 +163,14 @@ func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTar
return docsPermissionTarget{}
}
func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
if strings.TrimSpace(docURL) == "" {
return docsPermissionTarget{}, false
}
ref, err := parseDocumentRef(docURL)
if err != nil {
return docsPermissionTarget{}, false
}
switch ref.Kind {
case "wiki":
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
@@ -128,3 +180,68 @@ func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool
return docsPermissionTarget{}, false
}
}
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
// whiteboard creation markdown is detected.
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
}
}
// ── Shared helpers ──
// concatFlags combines multiple flag slices into one.
func concatFlags(slices ...[]common.Flag) []common.Flag {
var out []common.Flag
for _, s := range slices {
out = append(out, s...)
}
return out
}
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
m := make(map[string]string, len(v1)+len(v2))
for _, f := range v1 {
m[f.Name] = "v1"
}
for _, f := range v2 {
m[f.Name] = "v2"
}
return m
}

View File

@@ -9,15 +9,182 @@ import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
// ── V2 (OpenAPI) tests ──
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>项目计划</title><h1>目标</h1>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
}
// ── V1 (MCP) tests ──
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
@@ -59,77 +226,9 @@ func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
@@ -164,12 +263,6 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
@@ -180,6 +273,8 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
}
}
// ── Helpers ──
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
@@ -193,6 +288,18 @@ func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
}
}
func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
payload, _ := json.Marshal(result)
reg.Register(&httpmock.Stub{
@@ -214,15 +321,7 @@ func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interfa
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "docs"}
DocsCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
return mountAndRunDocs(t, DocsCreate, args, f, stdout)
}
func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
}
}
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("content") == "" {
return common.FlagErrorf("--content is required")
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateBody(runtime)
d := common.NewDryRunAPI().
POST("/open-apis/docs_ai/v1/documents").
Desc("OpenAPI: create document").
Body(body)
if runtime.IsBot() {
d.Desc("After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body := buildCreateBody(runtime)
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
return err
}
stripBlockIDs(data)
augmentDocsCreatePermission(runtime, data)
runtime.OutRaw(data, nil)
return nil
}
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"content": runtime.Str("content"),
}
if v := runtime.Str("parent-token"); v != "" {
body["parent_token"] = v
}
if v := runtime.Str("parent-position"); v != "" {
body["parent_position"] = v
}
injectDocsScene(runtime, body)
return body
}
// augmentDocsCreatePermission grants full_access to the current CLI user when
// the document was created with bot identity.
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
docID := strings.TrimSpace(common.GetString(doc, "document_id"))
if docID == "" {
return
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, docID, "docx"); grant != nil {
data["permission_grant"] = grant
}
}

View File

@@ -9,9 +9,45 @@ import (
"io"
"strconv"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
func v1FetchFlags() []common.Flag {
return []common.Flag{
{Name: "offset", Desc: "pagination offset", Hidden: true},
{Name: "limit", Desc: "pagination limit", Hidden: true},
}
}
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only
// flags with non-default values (bare "--doc xxx" stays on v1).
func useV2Fetch(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
// --doc-format default is "xml", --detail default is "simple", --revision-id default is -1.
// Only trigger auto-detect when a non-default value is present.
if d := runtime.Str("detail"); d != "" && d != "simple" {
return true
}
if f := runtime.Str("doc-format"); f != "" && f != "xml" {
return true
}
if runtime.Int("revision-id") != -1 {
return true
}
if m := runtime.Str("scope"); m != "" && m != "full" {
return true
}
return false
}
var DocsFetch = common.Shortcut{
Service: "docs",
Command: "+fetch",
@@ -20,66 +56,81 @@ var DocsFetch = common.Shortcut{
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "offset", Desc: "pagination offset"},
{Name: "limit", Desc: "pagination limit"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1FetchFlags(),
v2FetchFlags(),
),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
if useV2Fetch(runtime) {
return dryRunFetchV2(ctx, runtime)
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: fetch-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
Set("mcp_tool", "fetch-doc").Set("args", args)
return dryRunFetchV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
if useV2Fetch(runtime) {
return executeFetchV2(ctx, runtime)
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
return err
}
if md, ok := result["markdown"].(string); ok {
result["markdown"] = fixExportedMarkdown(md)
}
runtime.OutFormat(result, nil, func(w io.Writer) {
if title, ok := result["title"].(string); ok && title != "" {
fmt.Fprintf(w, "# %s\n\n", title)
}
if md, ok := result["markdown"].(string); ok {
fmt.Fprintln(w, md)
}
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
}
})
return nil
return executeFetchV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
},
}
// ── V1 (MCP) implementation ──
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildFetchArgsV1(runtime)
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: fetch-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
Set("mcp_tool", "fetch-doc").Set("args", args)
}
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+fetch")
args := buildFetchArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
return err
}
if md, ok := result["markdown"].(string); ok {
result["markdown"] = fixExportedMarkdown(md)
}
runtime.OutFormat(result, nil, func(w io.Writer) {
if title, ok := result["title"].(string); ok && title != "" {
fmt.Fprintf(w, "# %s\n\n", title)
}
if md, ok := result["markdown"].(string); ok {
fmt.Fprintln(w, md)
}
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
}
})
return nil
}
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
return args
}

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
}
}
func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
}
body := buildFetchBody(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
return common.NewDryRunAPI().
POST(apiPath).
Desc("OpenAPI: fetch document").
Body(body).
Set("document_id", ref.Token)
}
func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
if err := validateFetchDetail(runtime); err != nil {
return err
}
if err := validateReadModeFlags(runtime); err != nil {
return err
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
body := buildFetchBody(runtime)
data, err := doDocAPI(runtime, "POST", apiPath, body)
if err != nil {
return err
}
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
if content, ok := doc["content"].(string); ok {
fmt.Fprintln(w, content)
}
}
})
return nil
}
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
}
detail := runtime.Str("detail")
switch detail {
case "", "simple":
body["export_option"] = map[string]interface{}{
"export_block_id": false,
"export_style_attrs": false,
"export_cite_extra_data": false,
}
case "with-ids":
body["export_option"] = map[string]interface{}{
"export_block_id": true,
}
case "full":
body["export_option"] = map[string]interface{}{
"export_block_id": true,
"export_style_attrs": true,
"export_cite_extra_data": true,
}
}
if ro := buildReadOption(runtime); ro != nil {
body["read_option"] = ro
}
injectDocsScene(runtime, body)
return body
}
// buildReadOption 拼装 read_option JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
ro := map[string]interface{}{"read_mode": mode}
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
ro["start_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
ro["end_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("keyword")); v != "" {
ro["keyword"] = v
}
if v := runtime.Int("context-before"); v > 0 {
ro["context_before"] = strconv.Itoa(v)
}
if v := runtime.Int("context-after"); v > 0 {
ro["context_after"] = strconv.Itoa(v)
}
if v := runtime.Int("max-depth"); v >= 0 {
ro["max_depth"] = strconv.Itoa(v)
}
return ro
}
// validateFetchDetail 非 xml 格式markdown/text不承载 block_id 与样式属性,拒绝 with-ids/full。
func validateFetchDetail(runtime *common.RuntimeContext) error {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))
if format == "" || format == "xml" {
return nil
}
if detail == "with-ids" || detail == "full" {
return fmt.Errorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
}
return nil
}
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
func validateReadModeFlags(runtime *common.RuntimeContext) error {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
if v := runtime.Int("context-before"); v < 0 {
return fmt.Errorf("--context-before must be >= 0, got %d", v)
}
if v := runtime.Int("context-after"); v < 0 {
return fmt.Errorf("--context-after must be >= 0, got %d", v)
}
if v := runtime.Int("max-depth"); v < -1 {
return fmt.Errorf("--max-depth must be >= -1, got %d", v)
}
switch mode {
case "outline":
return nil
case "range":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
return fmt.Errorf("range mode requires --start-block-id or --end-block-id")
}
return nil
case "keyword":
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return fmt.Errorf("keyword mode requires --keyword")
}
return nil
case "section":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
return fmt.Errorf("section mode requires --start-block-id")
}
return nil
default:
return fmt.Errorf("invalid --scope %q", mode)
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
runtime := newFetchBodyTestRuntime(ctx)
body := buildFetchBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newCreateBodyTestRuntime(ctx)
body := buildCreateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newUpdateBodyTestRuntime(ctx)
body := buildUpdateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
body := buildFetchBody(runtime)
if _, ok := body["scene"]; ok {
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
}
}
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
cmd.Flags().String("end-block-id", "", "")
cmd.Flags().String("keyword", "", "")
cmd.Flags().Int("context-before", 0, "")
cmd.Flags().Int("context-after", 0, "")
cmd.Flags().Int("max-depth", -1, "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+create"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("content", "<title>hello</title>", "")
cmd.Flags().String("parent-token", "", "")
cmd.Flags().String("parent-position", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+update"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", 0, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}

View File

@@ -5,12 +5,13 @@ package doc
import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
var validModes = map[string]bool{
var validModesV1 = map[string]bool{
"append": true,
"overwrite": true,
"replace_range": true,
@@ -20,7 +21,7 @@ var validModes = map[string]bool{
"delete_range": true,
}
var needsSelection = map[string]bool{
var needsSelectionV1 = map[string]bool{
"replace_range": true,
"replace_all": true,
"insert_before": true,
@@ -28,6 +29,32 @@ var needsSelection = map[string]bool{
"delete_range": true,
}
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
func v1UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
{Name: "new-title", Desc: "also update document title", Hidden: true},
}
}
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Update(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("command") != "" ||
runtime.Str("content") != "" ||
runtime.Str("pattern") != "" ||
runtime.Str("block-id") != "" ||
runtime.Str("src-block-ids") != ""
}
var DocsUpdate = common.Shortcut{
Service: "docs",
Command: "+update",
@@ -35,124 +62,104 @@ var DocsUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Required: true},
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Input: []string{common.File, common.Stdin}},
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')"},
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')"},
{Name: "new-title", Desc: "also update document title"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1UpdateFlags(),
v2UpdateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
mode := runtime.Str("mode")
if !validModes[mode] {
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
if useV2Update(runtime) {
return validateUpdateV2(ctx, runtime)
}
if mode != "delete_range" && runtime.Str("markdown") == "" {
return common.FlagErrorf("--%s mode requires --markdown", mode)
}
selEllipsis := runtime.Str("selection-with-ellipsis")
selTitle := runtime.Str("selection-by-title")
if selEllipsis != "" && selTitle != "" {
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
}
if needsSelection[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
}
return nil
return validateUpdateV1(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
if useV2Update(runtime) {
return dryRunUpdateV2(ctx, runtime)
}
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: update-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
Set("mcp_tool", "update-doc").Set("args", args)
return dryRunUpdateV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
if useV2Update(runtime) {
return executeUpdateV2(ctx, runtime)
}
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
}
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
return executeUpdateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
},
}
func normalizeDocsUpdateResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
// ── V1 (MCP) implementation ──
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
mode := runtime.Str("mode")
if mode == "" {
return common.FlagErrorf("--mode is required")
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
if !validModesV1[mode] {
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
}
if mode != "delete_range" && runtime.Str("markdown") == "" {
return common.FlagErrorf("--%s mode requires --markdown", mode)
}
selEllipsis := runtime.Str("selection-with-ellipsis")
selTitle := runtime.Str("selection-by-title")
if selEllipsis != "" && selTitle != "" {
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
}
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
}
return nil
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildUpdateArgsV1(runtime)
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: update-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
Set("mcp_tool", "update-doc").Set("args", args)
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+update")
args := buildUpdateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
}
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
}
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
return args
}

View File

@@ -7,6 +7,32 @@ import (
"testing"
)
// ── V2 tests ──
func TestValidCommandsV2(t *testing.T) {
expected := map[string]bool{
"str_replace": true,
"str_delete": true,
"block_delete": true,
"block_insert_after": true,
"block_copy_insert_after": true,
"block_replace": true,
"block_move_after": true,
"overwrite": true,
"append": true,
}
if len(validCommandsV2) != len(expected) {
t.Fatalf("expected %d commands, got %d", len(expected), len(validCommandsV2))
}
for cmd := range validCommandsV2 {
if !expected[cmd] {
t.Fatalf("unexpected command %q in validCommandsV2", cmd)
}
}
}
// ── V1 tests ──
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
t.Run("blank whiteboard tags", func(t *testing.T) {
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
@@ -30,13 +56,13 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
})
}
func TestNormalizeDocsUpdateResult(t *testing.T) {
func TestNormalizeWhiteboardResult(t *testing.T) {
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
result := map[string]interface{}{
"success": true,
}
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
got, ok := result["board_tokens"].([]string)
if !ok {
@@ -52,7 +78,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
"board_tokens": []interface{}{"board_1", "board_2"},
}
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
want := []string{"board_1", "board_2"}
got, ok := result["board_tokens"].([]string)
@@ -69,7 +95,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
"success": true,
}
normalizeDocsUpdateResult(result, "## plain text")
normalizeWhiteboardResult(result, "## plain text")
if _, ok := result["board_tokens"]; ok {
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
var validCommandsV2 = map[string]bool{
"str_replace": true,
"str_delete": true,
"block_delete": true,
"block_insert_after": true,
"block_copy_insert_after": true,
"block_replace": true,
"block_move_after": true,
"overwrite": true,
"append": true,
}
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "command", Desc: "operation: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "regex pattern for str_replace / str_delete", Hidden: true},
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
}
}
func validCommandsV2Keys() []string {
return []string{"str_replace", "str_delete", "block_delete", "block_insert_after", "block_copy_insert_after", "block_replace", "block_move_after", "overwrite", "append"}
}
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
cmd := runtime.Str("command")
if cmd == "" {
return common.FlagErrorf("--command is required")
}
if !validCommandsV2[cmd] {
return common.FlagErrorf("invalid --command %q, valid: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
}
content := runtime.Str("content")
pattern := runtime.Str("pattern")
blockID := runtime.Str("block-id")
srcBlockIDs := runtime.Str("src-block-ids")
switch cmd {
case "str_replace":
if pattern == "" {
return common.FlagErrorf("--command str_replace requires --pattern")
}
if content == "" {
return common.FlagErrorf("--command str_replace requires --content")
}
case "str_delete":
if pattern == "" {
return common.FlagErrorf("--command str_delete requires --pattern")
}
case "block_delete":
if blockID == "" {
return common.FlagErrorf("--command block_delete requires --block-id")
}
case "block_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_insert_after requires --block-id")
}
if content == "" {
return common.FlagErrorf("--command block_insert_after requires --content")
}
case "block_copy_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
}
if srcBlockIDs == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
}
case "block_move_after":
if blockID == "" {
return common.FlagErrorf("--command block_move_after requires --block-id")
}
if content == "" && srcBlockIDs == "" {
return common.FlagErrorf("--command block_move_after requires --content or --src-block-ids")
}
case "block_replace":
if blockID == "" {
return common.FlagErrorf("--command block_replace requires --block-id")
}
if content == "" {
return common.FlagErrorf("--command block_replace requires --content")
}
case "overwrite":
if content == "" {
return common.FlagErrorf("--command overwrite requires --content")
}
case "append":
if content == "" {
return common.FlagErrorf("--command append requires --content")
}
}
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
}
body := buildUpdateBody(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
Desc("OpenAPI: update document").
Body(body).
Set("document_id", ref.Token)
}
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body := buildUpdateBody(runtime)
data, err := doDocAPI(runtime, "PUT", apiPath, body)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
}
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
cmd := runtime.Str("command")
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
blockID := runtime.Str("block-id")
if cmd == "append" {
cmd = "block_insert_after"
blockID = "-1"
}
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"command": cmd,
}
if v := runtime.Int("revision-id"); v != 0 {
body["revision_id"] = v
}
if v := runtime.Str("content"); v != "" {
body["content"] = v
}
if v := runtime.Str("pattern"); v != "" {
body["pattern"] = v
}
if blockID != "" {
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {
body["src_block_ids"] = v
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -4,12 +4,18 @@
package doc
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
// scene without exposing it as a user-controlled CLI flag.
const docsSceneContextKey = "lark_cli_docs_scene"
type documentRef struct {
Kind string
Token string
@@ -56,6 +62,40 @@ func extractDocumentToken(raw, marker string) (string, bool) {
return token, true
}
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
return runtime.DoAPIJSON(method, apiPath, nil, body)
}
func docsSceneFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
scene, _ := ctx.Value(docsSceneContextKey).(string)
return strings.TrimSpace(scene)
}
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
body["scene"] = scene
}
}
// stripBlockIDs removes "block_id" from each entry in data.document.newblocks.
func stripBlockIDs(data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
blocks, _ := doc["newblocks"].([]interface{})
for _, b := range blocks {
if m, ok := b.(map[string]interface{}); ok {
delete(m, "block_id")
}
}
}
func buildDriveRouteExtra(docID string) (string, error) {
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
if err != nil {

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/shortcuts/common"
)
// installVersionedHelp sets a custom help function on cmd that shows only the
// flags relevant to the selected --api-version. flagVersions maps flag name to
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
// always visible.
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
origHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
ver, _ := cmd.Flags().GetString("api-version")
if ver == "" {
ver = defaultVersion
}
// Show/hide flags based on the active version.
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if fv, ok := flagVersions[f.Name]; ok {
f.Hidden = fv != ver
}
})
origHelp(cmd, args)
if ver == "v1" {
fmt.Fprintf(cmd.OutOrStdout(),
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
" Use --api-version v2 for the latest API:\n"+
" %s %s --api-version v2 --help\n"+
" Upgrade skill:\n"+
" npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
cmd.Parent().Name(), cmd.Name())
}
})
}
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
// path is used, guiding users to upgrade their skill to v2.
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
fmt.Fprintf(runtime.IO().ErrOut,
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n"+
"Please upgrade your skill: npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
shortcut)
}

View File

@@ -707,4 +707,29 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s", formatted)
}
})
t.Run("ImChatMessageList dry run includes root-only query", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"page-size": "20",
"sort": "desc",
}, nil)
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(formatted, "only_thread_root_messages=true") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
})
}
func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"page-size": "20",
"sort": "desc",
}, nil)
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(formatted, "only_thread_root_messages=true") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
}

View File

@@ -152,8 +152,9 @@ func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]in
// This API has lighter permission requirements and works with user identity
// even when the target user is not in the app's visible range.
// Response uses "users" (not "items") and "user_id" (not "open_id").
// The basic_batch endpoint caps user_ids at 10 per request.
func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
const batchSize = 50
const batchSize = 10
for i := 0; i < len(missingIDs); i += batchSize {
end := i + batchSize
if end > len(missingIDs) {

View File

@@ -4,7 +4,9 @@
package convertlib
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
@@ -170,6 +172,57 @@ func TestResolveSenderNames(t *testing.T) {
}
}
func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
// expect three requests with sizes 10 / 10 / 5.
var batchSizes []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/basic_batch") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
userIDs, _ := payload["user_ids"].([]interface{})
if len(userIDs) > 10 {
t.Fatalf("batch exceeded API limit: size = %d", len(userIDs))
}
batchSizes = append(batchSizes, len(userIDs))
users := make([]interface{}, 0, len(userIDs))
for _, raw := range userIDs {
id, _ := raw.(string)
users = append(users, map[string]interface{}{
"user_id": id,
"name": "name-" + id,
})
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"users": users},
}), nil
}))
missingIDs := make([]string, 25)
for i := range missingIDs {
missingIDs[i] = fmt.Sprintf("ou_%02d", i)
}
nameMap := map[string]string{}
batchResolveByBasicContact(runtime, missingIDs, nameMap)
if want := []int{10, 10, 5}; !reflect.DeepEqual(batchSizes, want) {
t.Fatalf("batch sizes = %v, want %v", batchSizes, want)
}
if len(nameMap) != 25 {
t.Fatalf("resolved name count = %d, want 25", len(nameMap))
}
}
func TestResolveSenderNamesAPIFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {

View File

@@ -210,14 +210,15 @@ func TestBuildChatMessageListRequest(t *testing.T) {
}
want := larkcore.QueryParams{
"container_id_type": {"chat"},
"container_id": {"oc_123"},
"sort_type": {"ByCreateTimeAsc"},
"page_size": {"50"},
"card_msg_content_type": {"raw_card_content"},
"start_time": {"1772294400"},
"end_time": {"1772467199"},
"page_token": {"next"},
"container_id_type": {"chat"},
"container_id": {"oc_123"},
"sort_type": {"ByCreateTimeAsc"},
"page_size": {"50"},
"only_thread_root_messages": {"true"},
"card_msg_content_type": {"raw_card_content"},
"start_time": {"1772294400"},
"end_time": {"1772467199"},
"page_token": {"next"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildChatMessageListRequest() = %#v, want %#v", got, want)
@@ -245,6 +246,13 @@ func TestBuildChatMessageListRequest(t *testing.T) {
})
}
func TestChatMessageListOnlyThreadRootMessagesParams(t *testing.T) {
got := buildChatMessageListParams("desc", "20", "oc_123")
if vals := got["only_thread_root_messages"]; !reflect.DeepEqual(vals, []string{"true"}) {
t.Fatalf("only_thread_root_messages = %#v, want true", vals)
}
}
func TestResolveChatIDForMessagesList(t *testing.T) {
t.Run("chat passthrough", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{

View File

@@ -172,11 +172,12 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
pageSize = min(max(n, 1), 50)
}
return larkcore.QueryParams{
"container_id_type": []string{"chat"},
"container_id": []string{chatId},
"sort_type": []string{sortType},
"page_size": []string{strconv.Itoa(pageSize)},
"card_msg_content_type": []string{"raw_card_content"},
"container_id_type": []string{"chat"},
"container_id": []string{chatId},
"sort_type": []string{sortType},
"page_size": []string{strconv.Itoa(pageSize)},
"card_msg_content_type": []string{"raw_card_content"},
"only_thread_root_messages": []string{"true"},
}
}

View File

@@ -22,7 +22,7 @@ var SheetBatchSetStyle = common.Shortcut{
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
@@ -49,6 +49,7 @@ var SheetBatchSetStyle = common.Shortcut{
}
var data interface{}
json.Unmarshal([]byte(runtime.Str("data")), &data)
normalizeBatchStyleRanges(data)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
Body(map[string]interface{}{
@@ -66,6 +67,7 @@ var SheetBatchSetStyle = common.Shortcut{
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
normalizeBatchStyleRanges(data)
result, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
@@ -81,3 +83,34 @@ var SheetBatchSetStyle = common.Shortcut{
return nil
},
}
// normalizeBatchStyleRanges mutates each string entry in data[].ranges in place
// so the /styles_batch_update endpoint accepts single-cell shorthand.
// Entries carrying a sheetId! prefix (e.g. "sheet1!A1") are expanded to
// "sheet1!A1:A1"; multi-cell spans pass through unchanged.
// A bare single cell without the sheetId! prefix (e.g. "A1") cannot be
// expanded because the helper has no sheet-id context (the shortcut exposes
// no --sheet-id flag), and the backend would reject the payload anyway —
// such entries pass through unchanged. Non-string entries, missing
// ranges keys, and non-array top-level inputs are ignored silently.
func normalizeBatchStyleRanges(data interface{}) {
items, ok := data.([]interface{})
if !ok {
return
}
for _, item := range items {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
ranges, ok := entry["ranges"].([]interface{})
if !ok {
continue
}
for i, r := range ranges {
if s, ok := r.(string); ok {
ranges[i] = normalizePointRange("", s)
}
}
}
}

View File

@@ -414,6 +414,46 @@ func TestSheetSetStyleExecuteSuccess(t *testing.T) {
}
}
func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1",
"style": `{"font":{"bold":true}}`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"range":"sheet1!A1:A1"`) {
t.Fatalf("DryRun should expand single cell to A1:A1: %s", got)
}
}
func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "A1",
"--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
appendStyle, _ := body["appendStyle"].(map[string]interface{})
if appendStyle["range"] != "sheet1!A1:A1" {
t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"])
}
}
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
@@ -523,6 +563,51 @@ func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
}
}
func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) {
t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got)
}
}
func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"totalUpdatedCells": float64(5),
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
data, _ := body["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 data entry, got %d", len(data))
}
entry, _ := data[0].(map[string]interface{})
ranges, _ := entry["ranges"].([]interface{})
if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" {
t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges)
}
}
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
@@ -537,3 +622,101 @@ func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
t.Fatal("expected error")
}
}
func TestNormalizeBatchStyleRanges(t *testing.T) {
t.Parallel()
t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1", "sheet1!B2"},
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" {
t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got)
}
})
t.Run("multi-cell span passes through unchanged", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1:B2"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:B2" {
t.Fatalf("multi-cell span should be unchanged, got %v", got[0])
}
})
t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) {
t.Parallel()
// Without a sheetId! prefix there's no sheet context; entry is left
// alone and the backend will reject it. Documented in the helper.
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"A1"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "A1" {
t.Fatalf("bare single cell should pass through, got %v", got[0])
}
})
t.Run("non-string entries are preserved", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:A1" {
t.Fatalf("first entry should be expanded, got %v", got[0])
}
if got[1] != 42 {
t.Fatalf("int entry should be preserved, got %v", got[1])
}
if got[2] != nil {
t.Fatalf("nil entry should be preserved, got %v", got[2])
}
if got[3] != "sheet1!B2:B2" {
t.Fatalf("last entry should be expanded, got %v", got[3])
}
})
t.Run("missing or non-array ranges key is skipped", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
},
map[string]interface{}{
"ranges": "not-an-array",
},
"not-a-map",
}
normalizeBatchStyleRanges(data)
if data[1].(map[string]interface{})["ranges"] != "not-an-array" {
t.Fatal("non-array ranges should be left alone")
}
})
t.Run("top-level non-array inputs do not panic", func(t *testing.T) {
t.Parallel()
// Any of these would panic if the helper didn't guard its type assertions.
normalizeBatchStyleRanges(nil)
normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"})
normalizeBatchStyleRanges("string")
normalizeBatchStyleRanges(42)
})
}

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func floatImageBasePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func floatImageItemPath(token, sheetID, floatImageID string) string {
return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID))
}
func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if u := runtime.Str("url"); u != "" {
if parsed := extractSpreadsheetToken(u); parsed != u {
token = parsed
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func validateFloatImageRange(sheetID, rangeVal string) error {
if rangeVal == "" {
return nil
}
if err := validateSingleCellRange(rangeVal); err != nil {
return err
}
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
}
return nil
}
// validateFloatImageUpdatePayload rejects an update request that carries no
// mutable field. Without this, PATCH {} reaches the server as a confusing
// no-op or opaque error.
func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
hasField := runtime.Str("range") != "" ||
runtime.Cmd.Flags().Changed("width") ||
runtime.Cmd.Flags().Changed("height") ||
runtime.Cmd.Flags().Changed("offset-x") ||
runtime.Cmd.Flags().Changed("offset-y")
if !hasField {
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
}
return nil
}
// validateFloatImageDims checks the numeric bounds we can verify without
// fetching cell dimensions: width/height >= 20 and offset-x/offset-y >= 0.
// The upper bounds (offset < anchor cell's width/height) are validated by
// the server and surfaced through the 1310246 error hint.
// Only flags explicitly supplied by the user are checked, so omitted flags
// (which fall back to server defaults) pass through unchanged.
func validateFloatImageDims(runtime *common.RuntimeContext) error {
if runtime.Cmd.Flags().Changed("width") {
if v := runtime.Int("width"); v < 20 {
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("height") {
if v := runtime.Int("height"); v < 20 {
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("offset-x") {
if v := runtime.Int("offset-x"); v < 0 {
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("offset-y") {
if v := runtime.Int("offset-y"); v < 0 {
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
}
}
return nil
}
func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} {
body := map[string]interface{}{}
if includeToken {
if s := runtime.Str("float-image-token"); s != "" {
body["float_image_token"] = s
}
}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if runtime.Cmd.Flags().Changed("width") {
body["width"] = runtime.Int("width")
}
if runtime.Cmd.Flags().Changed("height") {
body["height"] = runtime.Int("height")
}
if runtime.Cmd.Flags().Changed("offset-x") {
body["offset_x"] = runtime.Int("offset-x")
}
if runtime.Cmd.Flags().Changed("offset-y") {
body["offset_y"] = runtime.Int("offset-y")
}
return body
}
// SheetCreateFloatImage creates a float image on a sheet.
var SheetCreateFloatImage = common.Shortcut{
Service: "sheets",
Command: "+create-float-image",
Description: "Create a floating image on a sheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-token", Desc: "image file token (from upload API)", Required: true},
{Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true},
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
{Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFloatImageToken(runtime); err != nil {
return err
}
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return validateFloatImageDims(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, true)
if s := runtime.Str("float-image-id"); s != "" {
body["float_image_id"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, true)
if s := runtime.Str("float-image-id"); s != "" {
body["float_image_id"] = s
}
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetUpdateFloatImage updates a float image's properties.
var SheetUpdateFloatImage = common.Shortcut{
Service: "sheets",
Command: "+update-float-image",
Description: "Update a floating image",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
{Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"},
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFloatImageToken(runtime); err != nil {
return err
}
if err := validateFloatImageUpdatePayload(runtime); err != nil {
return err
}
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return validateFloatImageDims(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, false)
return common.NewDryRunAPI().
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, false)
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetGetFloatImage retrieves a single float image.
var SheetGetFloatImage = common.Shortcut{
Service: "sheets",
Command: "+get-float-image",
Description: "Get a floating image by ID",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetListFloatImages queries all float images in a sheet.
var SheetListFloatImages = common.Shortcut{
Service: "sheets",
Command: "+list-float-images",
Description: "List all floating images in a sheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetDeleteFloatImage deletes a float image.
var SheetDeleteFloatImage = common.Shortcut{
Service: "sheets",
Command: "+delete-float-image",
Description: "Delete a floating image",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,524 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── CreateFloatImage ────────────────────────────────────────────────────────
func TestCreateFloatImageValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestCreateFloatImageValidateSuccess(t *testing.T) {
t.Parallel()
// Pixel flags are int-typed by the shortcut; leave them unset (empty
// intFlags map) so Cmd.Flags().Changed(...) returns false and
// validateFloatImageDims doesn't try to read non-existent ints.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
}, nil, nil)
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:B2",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "single cell") {
t.Fatalf("expected single-cell error, got: %v", err)
}
}
func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-token": "boxToken", "range": "other!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
}
}
func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
t.Parallel()
tests := []struct {
name string
intFlags map[string]int
wantSubst string
}{
{"width below 20", map[string]int{"width": 5}, "--width must be >= 20"},
{"height below 20", map[string]int{"height": 10}, "--height must be >= 20"},
{"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"},
{"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"},
}
baseStr := map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) {
t.Parallel()
// Boundary values exactly at the lower bound should pass.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
},
map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil)
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("boundary values should pass, got: %v", err)
}
}
func TestCreateFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "",
},
map[string]int{"width": 200, "height": 150}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `float_images`) {
t.Fatalf("DryRun URL missing float_images: %s", got)
}
if !strings.Contains(got, `"float_image_token":"boxToken"`) {
t.Fatalf("DryRun missing float_image_token: %s", got)
}
if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) {
t.Fatalf("DryRun should emit numeric width/height, got: %s", got)
}
}
func TestCreateFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{
"float_image_id": "fi12345678", "float_image_token": "boxToken",
"range": "sheet1!A1:A1", "width": 200, "height": 150,
},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--width", "200", "--height", "150",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "float_image_id") {
t.Fatalf("stdout missing float_image_id: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["float_image_token"] != "boxToken" {
t.Fatalf("unexpected float_image_token: %v", body["float_image_token"])
}
if w, ok := body["width"].(float64); !ok || w != 200 {
t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"])
}
if h, ok := body["height"].(float64); !ok || h != 150 {
t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"])
}
}
func TestCreateFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi12345678"},
}},
})
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFloatImageExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UpdateFloatImage ────────────────────────────────────────────────────────
func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) {
t.Parallel()
// Only IDs set, no mutable field: PATCH would be an empty {} body.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
}, nil, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") {
t.Fatalf("expected empty-payload error, got: %v", err)
}
}
func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) {
t.Parallel()
// Any single mutable field should satisfy the payload check.
tests := []struct {
name string
strFlags map[string]string
intFlags map[string]int
}{
{
name: "range only",
strFlags: map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "sheet1!B2:B2",
},
},
{
name: "offset-x only (zero value)",
strFlags: map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
},
intFlags: map[string]int{"offset-x": 0},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil)
if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "other!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "",
}, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
}
}
func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
t.Parallel()
tests := []struct {
name string
intFlags map[string]int
wantSubst string
}{
{"width below 20", map[string]int{"width": 19}, "--width must be >= 20"},
{"height below 20", map[string]int{"height": 0}, "--height must be >= 20"},
{"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"},
{"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"},
}
baseStr := map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestUpdateFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"float-image-id": "fi12345678", "range": "sheet1!B2:B2",
},
map[string]int{"width": 300, "offset-y": 10}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PATCH"`) {
t.Fatalf("DryRun should use PATCH: %s", got)
}
if !strings.Contains(got, `fi12345678`) {
t.Fatalf("DryRun missing float_image_id: %s", got)
}
if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) {
t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got)
}
}
func TestUpdateFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300},
}},
})
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
"+update-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123",
"--width", "300", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
"+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123",
"--range", "sheet1!C3:C3", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetFloatImage ───────────────────────────────────────────────────────────
func TestGetFloatImageValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1",
}, nil)
err := SheetGetFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGetFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `fi123`) {
t.Fatalf("DryRun missing float_image_id: %s", got)
}
}
func TestGetFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{
"float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100,
},
}},
})
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
"+get-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fi123") {
t.Fatalf("stdout missing fi123: %s", stdout.String())
}
}
func TestGetFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123"},
}},
})
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
"+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── ListFloatImages ─────────────────────────────────────────────────────────
func TestListFloatImagesDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt))
if !strings.Contains(got, `float_images/query`) {
t.Fatalf("DryRun URL missing query: %s", got)
}
}
func TestListFloatImagesExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"float_image_id": "fi1"},
map[string]interface{}{"float_image_id": "fi2"},
},
}},
})
err := mountAndRunSheets(t, SheetListFloatImages, []string{
"+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fi1") {
t.Fatalf("stdout missing fi1: %s", stdout.String())
}
}
func TestListFloatImagesWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFloatImages, []string{
"+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFloatImage ────────────────────────────────────────────────────────
func TestDeleteFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
"+delete-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
"+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// sheetImageParentType is the parent_type accepted by the drive media upload
// endpoint for media that will be anchored via +create-float-image.
const sheetImageParentType = "sheet_image"
// SheetMediaUpload uploads a local image to the drive media endpoint against
// a spreadsheet and returns the file_token. The token is usable as the
// --float-image-token argument to +create-float-image.
//
// Files up to 20 MB go through /drive/v1/medias/upload_all; larger files are
// streamed via upload_prepare / upload_part / upload_finish. This matches the
// pattern used by docs +media-upload and drive +import.
var SheetMediaUpload = common.Shortcut{
Service: "sheets",
Command: "+media-upload",
Description: "Upload a local image for use as a floating image and return the file_token",
Risk: "write",
Scopes: []string{"docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
parentNode, err := resolveSheetMediaUploadParent(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
filePath := runtime.Str("file")
fileName := filepath.Base(filePath)
dry := common.NewDryRunAPI()
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
dry.Desc("chunked media upload (files > 20MB)").
POST("/open-apis/drive/v1/medias/upload_prepare").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": sheetImageParentType,
"parent_node": parentNode,
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return dry.Set("spreadsheet_token", parentNode)
}
return dry.Desc("multipart/form-data upload").
POST("/open-apis/drive/v1/medias/upload_all").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": sheetImageParentType,
"parent_node": parentNode,
"size": "<file_size>",
"file": "@" + filePath,
}).
Set("spreadsheet_token", parentNode)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
parentNode, err := resolveSheetMediaUploadParent(runtime)
if err != nil {
return err
}
filePath := runtime.Str("file")
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
}
fileName := filepath.Base(filePath)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
fileToken, err := uploadSheetMediaFile(runtime, filePath, fileName, stat.Size(), parentNode)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"size": stat.Size(),
"spreadsheet_token": parentNode,
}, nil)
return nil
},
}
// resolveSheetMediaUploadParent returns the spreadsheet token to use as parent_node,
// accepting either --url or --spreadsheet-token.
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if u := runtime.Str("url"); u != "" {
if parsed := extractSpreadsheetToken(u); parsed != "" {
token = parsed
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
// uploadSheetMediaFile routes to the single-part or multipart upload path based
// on file size. Always uses parent_type=sheet_image so the returned token can
// be consumed by +create-float-image.
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
pn := parentNode
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: sheetImageParentType,
ParentNode: &pn,
})
}
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: sheetImageParentType,
ParentNode: parentNode,
})
}
// sheetMediaShouldUseMultipart mirrors docMediaShouldUseMultipart: dry-run uses
// local stat as a best-effort planning hint. Execute re-validates before
// choosing the actual upload path.
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
info, err := fio.Stat(filePath)
if err != nil {
return false
}
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSheetMediaUploadValidateMissingToken(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload", "--file", "img.png", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
}
if !strings.Contains(out, `"sheet_image"`) {
t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out)
}
if strings.Contains(out, "upload_prepare") {
t.Fatalf("dry-run should not use multipart for small file, got: %s", out)
}
}
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "shtFromURL") {
t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String())
}
}
func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
// Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk.
largeFile, err := os.Create("big.png")
if err != nil {
t.Fatal(err)
}
if err := largeFile.Truncate(20*1024*1024 + 1); err != nil {
t.Fatal(err)
}
_ = largeFile.Close()
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err = mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "big.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v1/medias/upload_prepare",
"/open-apis/drive/v1/medias/upload_part",
"/open-apis/drive/v1/medias/upload_finish",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run should include %q for large file, got: %s", want, out)
}
}
if strings.Contains(out, "upload_all") {
t.Fatalf("dry-run should not use upload_all for large file, got: %s", out)
}
}
func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "boxTOK123"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "img.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("parse output: %v", err)
}
data, _ := envelope["data"].(map[string]interface{})
if data["file_token"] != "boxTOK123" {
t.Fatalf("file_token = %v, want boxTOK123", data["file_token"])
}
if data["spreadsheet_token"] != "shtSTUB" {
t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"])
}
body := decodeSheetsMultipartBody(t, stub)
if got := body.Fields["parent_type"]; got != sheetImageParentType {
t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType)
}
if got := body.Fields["parent_node"]; got != "shtSTUB" {
t.Fatalf("parent_node = %q, want shtSTUB", got)
}
if got := body.Fields["file_name"]; got != "img.png" {
t.Fatalf("file_name = %q, want img.png", got)
}
if got := body.Fields["size"]; got != "9" {
t.Fatalf("size = %q, want 9 (len of png-bytes)", got)
}
}
func TestSheetMediaUploadFileNotFound(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "missing.png",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
t.Fatalf("err = %v, want file-not-found error", err)
}
}
// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with
// t.Parallel — chdir is process-wide.
func withSheetsTestWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
}
type capturedSheetsMultipart struct {
Fields map[string]string
Files map[string][]byte
}
func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse content-type %q: %v", contentType, err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
for {
part, err := reader.NextPart()
if err != nil {
break
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(part)
if part.FileName() != "" {
body.Files[part.FormName()] = buf.Bytes()
continue
}
body.Fields[part.FormName()] = buf.String()
}
return body
}

View File

@@ -51,7 +51,7 @@ var SheetSetStyle = common.Shortcut{
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
json.Unmarshal([]byte(runtime.Str("style")), &style)
return common.NewDryRunAPI().
@@ -70,7 +70,7 @@ var SheetSetStyle = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)

View File

@@ -40,5 +40,11 @@ func Shortcuts() []common.Shortcut {
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
SheetMediaUpload,
SheetCreateFloatImage,
SheetUpdateFloatImage,
SheetGetFloatImage,
SheetListFloatImages,
SheetDeleteFloatImage,
}
}

View File

@@ -102,8 +102,8 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--selection-with-ellipsis` 或 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--selection-with-ellipsis` / `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 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 |
| 列出文档评论 | `file_token` | 同添加评论 |
@@ -111,8 +111,8 @@ Drive Folder (云空间文件夹)
### 评论能力边界(关键!)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--selection-with-ellipsis` / `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--selection-with-ellipsis` 或 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --detail with-ids` 获取。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。

View File

@@ -26,9 +26,11 @@ lark-cli approval <resource> <method> [flags] # 调用 API
- `get` — 获取单个审批实例详情
- `cancel` — 撤回审批实例
- `cc` — 抄送审批实例
- `initiated` — 查询用户的已发起列表
### tasks
- `remind` — 催办审批人
- `approve` — 同意审批任务
- `reject` — 拒绝审批任务
- `transfer` — 转交审批任务
@@ -41,6 +43,8 @@ lark-cli approval <resource> <method> [flags] # 调用 API
| `instances.get` | `approval:instance:read` |
| `instances.cancel` | `approval:instance:write` |
| `instances.cc` | `approval:instance:write` |
| `instances.initiated` | `approval:instance:read` |
| `tasks.remind` | `approval:instance:write` |
| `tasks.approve` | `approval:task:write` |
| `tasks.reject` | `approval:task:write` |
| `tasks.transfer` | `approval:task:write` |

View File

@@ -1,127 +1,55 @@
---
name: lark-doc
version: 1.0.0
description: "飞书云文档:创建和编辑飞书文档。 Markdown 创建文档、获取文档内容、更新文档(追加/覆盖/替换/插入/删除)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
version: 2.0.0
description: "飞书云文档:创建和编辑飞书文档。默认使用 DocxXML 格式(也支持 Markdown)。创建文档、获取文档内容(支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文、更新文档八种指令str_replace/str_delete/block_insert_after/block_replace/block_delete/block_move_after/overwrite/append)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help"
---
# docs (v1)
# docs (v2)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 核心概念
### 文档类型与 Token
飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`
### 文档 URL 格式与 Token 处理
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|----------|---------------------------------------------------------|-----------|----------|
| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
### Wiki 链接特殊处理(关键!)
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。
#### 处理流程
1. **使用 `wiki.spaces.get_node` 查询节点信息**
```bash
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
```
2. **从返回结果中提取关键信息**
- `node.obj_type`文档类型docx/doc/sheet/bitable/slides/file/mindnote
- `node.obj_token`**真实的文档 token**(用于后续操作)
- `node.title`:文档标题
3. **根据 `obj_type` 使用对应的 API**
| obj_type | 说明 | 使用的 API |
|----------|------|-----------|
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
| `doc` | 旧版云文档 | `drive file.comments.*` |
| `sheet` | 电子表格 | `sheets.*` |
| `bitable` | 多维表格 | `bitable.*` |
| `slides` | 幻灯片 | `drive.*` |
| `file` | 文件 | `drive.*` |
| `mindnote` | 思维导图 | `drive.*` |
#### 查询示例
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create`、`docs +fetch`、`docs +update` 命令必须携带 `--api-version v2`。**
```bash
# 查询 wiki 节点
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
# 常用示例
lark-cli docs +fetch --api-version v2 --doc "文档URL或token"
lark-cli docs +create --api-version v2 --content '<title>标题</title><p>内容</p>'
lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '<p>内容</p>'
```
返回结果示例:
```json
{
"node": {
"obj_type": "docx",
"obj_token": "xxxx",
"title": "标题",
"node_type": "origin",
"space_id": "12345678910"
}
}
```
## 前置条件 — 执行操作前必读
### 资源关系
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
```
Wiki Space (知识空间)
└── Wiki Node (知识库节点)
├── obj_type: docx (新版文档)
│ └── obj_token (真实文档 token)
├── obj_type: doc (旧版文档)
│ └── obj_token (真实文档 token)
├── obj_type: sheet (电子表格)
│ └── obj_token (真实文档 token)
├── obj_type: bitable (多维表格)
│ └── obj_token (真实文档 token)
└── obj_type: file/slides/mindnote
└── obj_token (真实文档 token)
**未读完以上文件就执行相应操作会导致参数选择错误、格式错误或样式不达标。**
Drive Folder (云空间文件夹)
└── File (文件/文档)
└── file_token (直接使用)
```
## 绘图需求识别与挖掘
用户很少主动提"画板"——**默认**使用飞书画板承载图表,命中以下任一信号即触发:
- 用户提到图表类型:架构图、流程图、时序图、组织图、路线图、对比图、鱼骨图、飞轮图、思维导图等
- 用户表达可视化意图:画一下、梳理关系、画个流程、给我一个图、方便汇报等
- 文档主题涉及结构关系、流程走向、时间线、数据对比
以下场景不加图:用户明确拒绝、合同/法律条款/合规声明等严谨连续文本、原样转录任务。
> [!CAUTION]
> 命中后,**MUST** 先读取 [`references/lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 并**严格按其流程执行**。
>
> **绝对禁止**用 `whiteboard-cli` 渲染 PNG 后通过 `docs +media-insert` 插入文档——图表必须通过 `lark-cli whiteboard +update` 写入画板 block这是唯一合法路径。
> **格式选择规则(全局):** `docs +create` 和 `docs +update` 始终使用 XML 格式(`--doc-format xml`,即默认值),除非用户明确要求使用 Markdown。XML 支持 callout、grid、checkbox 等丰富 block 类型——不要因为 Markdown 更简单就自行切换。
## 快速决策
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `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` 处理。
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `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" → 切到 `lark-drive` 处理
- 文档内容中出现嵌入的 `<sheet>``<bitable>``<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
## 补充说明
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
| 标签 / 属性 | 提取字段 | 切到技能 |
|-|-|-|
| `<sheet token="..." sheet-id="...">` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<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` 读取 src-token 文档,定位 block |
**补充:** `docs +search` 也承担"先定位云空间对象,再切回对应业务 skill 操作"的资源发现入口角色;当用户口头说"表格/报表"时,也优先从这里开始。
## Shortcuts推荐优先使用
@@ -130,9 +58,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-doc-search.md) | Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search) |
| [`+create`](references/lark-doc-create.md) | Create a Lark document |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content |
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
| [`+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) |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (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,672 +1,89 @@
# docs +create创建飞书云文档
> **前置条件** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证、全局参数和安全规则
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 3. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
> 4. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
Lark-flavored Markdown 内容创建一个新的飞书云文档。
XML默认 Markdown 内容创建一个新的飞书云文档。
## 重要说明
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
> **⚠️ 格式选择规则:始终使用 XML 格式(默认),除非用户明确要求使用 Markdown。** XML 表达能力更强、支持更多 block 类型callout、grid、checkbox 等),是推荐的首选格式。不要因为 Markdown 写起来更简单就自行切换为 Markdown。
## 命令
```bash
# 创建简单文档
lark-cli docs +create --title "项目计划" --markdown "## 目标\n\n- 目标 1\n- 目标 2"
# 创建 XML 文档(默认格式,推荐)
lark-cli docs +create --api-version v2 --content '<title>项目计划</title><h1>目标</h1><ul><li>目标 1</li><li>目标 2</li></ul>'
# 创建到指定文件夹
lark-cli docs +create --title "会议纪要" --folder-token fldcnXXXX --markdown "## 讨论议题\n\n1. 进度\n2. 计划"
# 创建到指定文件夹XML
lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<title>标题</title><p>首段内容</p>'
# 创建到知识库节点下
lark-cli docs +create --title "技术文档" --wiki-node wikcnXXXX --markdown "## API 说明"
# 创建到个人知识库XML
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
# 创建到知识空间根目录
lark-cli docs +create --title "概览" --wiki-space 7000000000000000000 --markdown "## 项目概览"
# 创建到个人知识库
lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown "## 笔记"
# 仅当用户明确要求时才使用 Markdown
lark-cli docs +create --api-version v2 --doc-format markdown --content "# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2"
```
## 返回值
工具成功执行后,返回一个 JSON 对象,包含以下字段:
```json
{
"ok": true,
"identity": "user",
"data": {
"document": {
"document_id": "doxcnXXXXXXXXXXXXXXXXXXX",
"revision_id": 1,
"url": "https://xxx.feishu.cn/docx/doxcnXXXXXXXXXXXXXXXXXXX",
"newblocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "token": "boardXXXX" }
]
}
}
}
```
- **`doc_id`**string文档的唯一标识符token格式如 `doxcnXXXXXXXXXXXXXXXXXXX`
- **`doc_url`**string文档的访问链接可直接在浏览器中打开
- **`message`**string操作结果消息如"文档创建成功"
- **`permission_grant`**object可选`--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
- **`document.newblocks`**:本次操作新增的 block 列表(如画板),可从中提取 `token` 用于后续编辑
> [!IMPORTANT]
> 当文档创建在 `wiki_node` 或 `wiki_space` 下时,返回的 `doc_url` 可能是 `/wiki/...` 形式的知识库链接,而不是 `/docx/...` 形式的文档链接
> 如果后续要调用 [`lark-doc-media-insert`](lark-doc-media-insert.md) 这类当前只支持 `doc_id` 或 `/docx/...` URL 自动提取的 skill请优先使用返回值里的 `doc_id`,不要直接复用这个 `doc_url`。
> [!IMPORTANT]
> 如果文档是**以应用身份bot创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后, CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。
> \[!IMPORTANT]
> 如果文档是**以应用身份bot创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**
>
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
> - `status = granted`:当前 CLI 用户已获得该文档的可管理权限
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份bot授予当前用户权限
> - `status = failed`:文档已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文档
>
> `permission_grant.perm = full_access` 表示该资源已授予可管理权限”。
> `permission_grant.perm = full_access` 表示该资源已授予可管理权限”。
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
## 重要:创建文档后的可视化流程
如果文档中包含空白画板(`<whiteboard type="blank"></whiteboard>`**必须继续以下步骤**
1. 从返回值的 `data.board_tokens` 字段记录所有新建画板的 token
2. 读取 `../../lark-whiteboard/SKILL.md`,跳至"渲染 & 写入画板"章节,为每个 board_token 生成并写入实际内容
3. 确认所有画板都有实际内容后,任务才算完成
**仅创建空白画板是不够的!** 如果只创建空白画板而不填充内容,任务将被视为未完成。
> ⚠️ **警告**:务必检查返回值中是否有 `board_tokens` 字段。如果有,说明创建了空白画板,必须继续填充内容!
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--markdown` | 是 | 文档的 Markdown 内容Lark-flavored Markdown 格式) |
| `--title` | | 文档标题 |
| `--folder-token` | 否 | 父文件夹 token`--wiki-node``--wiki-space` 互斥 |
| `--wiki-node` | 否 | 知识库节点 token 或 URL(与 `--folder-token``--wiki-space` 互斥) |
| `--wiki-space` | 否 | 知识空间 ID特殊值 `my_library` 表示个人知识库(与 `--folder-token``--wiki-node` 互斥) |
### markdown必填
文档的 Markdown 内容,使用 Lark-flavored Markdown 格式。
调用本工具的 markdown 内容应当尽量结构清晰,样式丰富,有很高的可读性。合理地使用 callout 高亮块、分栏、表格、图片和空白画板等能力,做到图文并茂。
你需要遵循以下原则:
- **结构清晰**:标题层级 ≤ 4 层,用 Callout 突出关键信息
- **视觉节奏**:用分割线、分栏、表格打破大段纯文字
- **图文交融**:流程、架构或草图需要可视化时,优先使用图片、表格或空白画板
- **克制留白**Callout 不过度、加粗只强调核心词
- **主动画板**:文档涉及架构、流程、组织、时间线、因果等逻辑关系时,**必须**在 markdown 对应章节的文字内容之后插入 `<whiteboard type="blank"></whiteboard>` 占位,每个图表对应一个标签。**禁止**用 `whiteboard-cli` 渲染的 PNG/SVG 图片替代画板。创建完成后从返回值 `data.board_tokens` 取 token读取 `../../lark-whiteboard/SKILL.md` 的"渲染 & 写入画板"章节为每个 token 写入图表内容。例:文档含"系统整体架构""分层架构""部署架构"各需插入一个画板,"类图"也需插入一个画板(走 Mermaid 路由)。
当用户有明确的样式、风格需求时,应当以用户的需求为准!
**重要提示**
- **禁止重复标题**markdown 内容开头不要写与 title 相同的一级标题title 参数已经是文档标题markdown 应直接从正文内容开始
- **目录**:飞书自动生成,无需手动添加
- Markdown 语法必须符合 Lark-flavored Markdown 规范,详见下方"内容格式"章节
- 创建较长的文档时,强烈建议配合 `docs +update --mode append`,进行分段的创建,提高成功率
### folder-token可选
父文件夹的 token。如果不提供文档将创建在用户的个人空间根目录。
folder_token 可以从飞书文件夹 URL 中获取,格式如:`https://xxx.feishu.cn/drive/folder/fldcnXXXX`,其中 `fldcnXXXX` 即为 folder_token。
### wiki-node可选
知识库节点 token 或 URL可选传入则在该节点下创建文档与 folder-token 和 wiki-space 互斥)
wiki_node 可以从飞书知识库页面 URL 中获取,格式如:`https://xxx.feishu.cn/wiki/wikcnXXXX`,其中 `wikcnXXXX` 即为 wiki_node token。
### wiki-space可选
知识空间 ID可选传入则在该空间根目录下创建文档。特殊值 `my_library` 表示用户的个人知识库。与 wiki-node 和 folder-token 互斥)
wiki_space 可以从知识空间设置页面 URL 中获取,格式如:`https://xxx.feishu.cn/wiki/settings/7000000000000000000`,其中 `7000000000000000000` 即为 wiki_space ID。
**参数优先级**wiki-node > wiki-space > folder-token
## 示例
### 示例 1创建简单文档
```bash
lark-cli docs +create --title "项目计划" --markdown "## 项目概述\n\n这是一个新项目。\n\n## 目标\n\n- 目标 1\n- 目标 2"
```
### 示例 2使用飞书扩展语法
```bash
lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" background-color="light-blue">\n重要需求说明\n</callout>'
```
# 内容格式
文档内容使用 **Lark-flavored Markdown** 格式,这是标准 Markdown 的扩展版本,支持飞书文档的所有块类型和富文本格式。
## 通用规则
- 使用标准 Markdown 语法作为基础
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3``version~1.0``final_trajectory` 通常应保持原样,只有像 `*斜体*``**粗体**``~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
---
## 基础块类型
### 文本(段落)
```markdown
普通文本段落
段落中的**粗体文字**
多个段落之间用空行分隔。
居中文本 {align="center"}
右对齐文本 {align="right"}
```
**段落对齐**:支持 `{align="left|center|right"}` 语法。可与颜色组合:`{color="blue" align="center"}`
### 标题
飞书支持 9 级标题。H1-H6 使用标准 Markdown 语法H7-H9 使用 HTML 标签:
```markdown
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
<h7>七级标题</h7>
<h8>八级标题</h8>
<h9>九级标题</h9>
# 带颜色的标题 {color="blue"}
## 红色标题 {color="red"}
# 居中标题 {align="center"}
## 蓝色居中标题 {color="blue" align="center"}
```
**标题属性**:支持 `{color="颜色名"}``{align="left|center|right"}` 语法可组合使用。颜色值red, orange, yellow, green, blue, purple, gray。请谨慎使用该能力。
### 列表
有序列表、无序列表嵌套使用 tab 或者 2 空格缩进:
```markdown
- 无序项1
- 无序项1.a
- 无序项1.b
1. 有序项1
2. 有序项2
- [ ] 待办
- [x] 已完成
```
### 引用块
```markdown
> 这是一段引用
> 可以跨多行
> 引用中支持**加粗**和*斜体*等格式
```
### 代码块
**注意**:只支持围栏代码块(` ``` `),不支持缩进代码块。
````markdown
```python
print("Hello")
```
````
支持语言python, javascript, go, java, sql, json, yaml, shell 等。
### 分割线
```markdown
---
```
---
## 富文本格式
### 文本样式
`**粗体**` `*斜体*` `~~删除线~~` `` `行内代码` `` `<u>下划线</u>`
### 文字颜色
`<text color="red">红色</text>` `<text background-color="yellow">黄色背景</text>`
支持: red, orange, yellow, green, blue, purple, gray
### 链接
`[链接文字](https://example.com)` (不支持锚点链接)
### 行内公式LaTeX
`$E = mc^2$``$`前后需空格)或 `<equation>E = mc^2</equation>`(无限制,推荐)
---
## 高级块类型
### 高亮块Callout
```html
<callout emoji="✅" background-color="light-green" border-color="green">
支持**格式化**的内容,可包含多个块
</callout>
```
**属性**: emoji (使用 emoji 字符如 ✅ ⚠️ 💡), background-color, border-color, text-color
**背景色**: light-red/red, light-blue/blue, light-green/green, light-yellow/yellow, light-orange/orange, light-purple/purple, pale-gray/light-gray/dark-gray
**常用**: 💡light-blue(提示) ⚠light-yellow(警告) ❌light-red(危险) ✅light-green(成功)
**限制**: callout 子块仅支持文本、标题、列表、待办、引用。不支持代码块、表格、图片。
### 分栏Grid
适合对比、并列展示场景。支持 2-5 列:
#### 两栏(等宽)
```html
<grid cols="2">
<column>
左栏内容
</column>
<column>
右栏内容
</column>
</grid>
```
#### 三栏自定义宽度
```html
<grid cols="3">
<column width="20">左栏(20%)</column>
<column width="60">中栏(60%)</column>
<column width="20">右栏(20%)</column>
</grid>
```
**属性**: `cols`(列数 2-5), `width`(列宽百分比,总和为 100等宽时可省略)
### 表格
#### 标准 Markdown 表格
```markdown
| 列 1 | 列 2 | 列 3 |
|------|------|------|
| 单元格 1 | 单元格 2 | 单元格 3 |
| 单元格 4 | 单元格 5 | 单元格 6 |
```
#### 飞书增强表格
当单元格需要复杂内容(列表、代码块、高亮块等)时使用。
**层级结构**(必须严格遵守):
```
<lark-table> <- 表格容器
<lark-tr> <- 行(直接子元素只能是 lark-tr
<lark-td>内容</lark-td> <- 单元格(直接子元素只能是 lark-td
<lark-td>内容</lark-td> <- 每行的 lark-td 数量必须相同!
</lark-tr>
</lark-table>
```
**属性**
- `column-widths`:列宽,逗号分隔像素值,总宽约 730
- `header-row`:首行是否为表头(`"true"` 或 `"false"`
- `header-column`:首列是否为表头(`"true"` 或 `"false"`
**单元格写法**:内容前后必须空行
```html
<lark-td>
这里写内容
</lark-td>
```
**完整示例**2行3列
```html
<lark-table column-widths="200,250,280" header-row="true">
<lark-tr>
<lark-td>
**表头1**
</lark-td>
<lark-td>
**表头2**
</lark-td>
<lark-td>
**表头3**
</lark-td>
</lark-tr>
<lark-tr>
<lark-td>
普通文本
</lark-td>
<lark-td>
- 列表项1
- 列表项2
</lark-td>
<lark-td>
代码内容
</lark-td>
</lark-tr>
</lark-table>
```
**限制**:单元格内不支持 Grid 和嵌套表格
**合并单元格**:读取时返回 `rowspan/colspan` 属性,创建暂不支持
**禁止**
- 混用 Markdown 表格语法(`|---|`
- 使用 `<br/>` 换行
- 遗漏 `<lark-td>` 标签
### 图片
```html
<image url="https://example.com/image.png" width="800" height="600" align="center" caption="图片描述文字"/>
```
**属性**: url (必需,系统会自动下载并上传), width, height, align (left/center/right), caption
**注意**: 不支持直接使用 `token` 属性(如 `<image token="xxx"/>`),只支持 URL 方式。系统会自动下载图片并上传到飞书。
支持 PNG/JPG/GIF/WebP/BMP最大 10MB
**图片/文件插入方式选择**
- **有公开可访问的图片 URL** → 直接在 `docs +create` / `docs +update` 的 markdown 中使用 `<image url="..."/>` 一步到位
- **本地图片或文件** → 先用 `docs +create` / `docs +update` 创建或更新文档文本内容,再用 `lark-doc-media-insert`docs +media-insert将本地图片或文件追加到文档末尾
### 文件
```html
<file url="https://example.com/document.pdf" name="文档.pdf" view-type="1"/>
```
**属性**:
- url (文件 URL必需系统会自动下载并上传)
- name (文件名,必需)
- view-type (1=卡片视图, 2=预览视图,可选)
**注意**: 不支持直接使用 `token` 属性(如 `<file token="xxx"/>`
### 画板
创建空白画板时,直接在 markdown 中写 `<whiteboard type="blank"></whiteboard>`。
自然语言请求示例:
- “帮我创建一个带单个空白画板的文档”
- “帮我创建一个文档,里面放两个空白画板”
```bash
# 创建带单个空白画板的文档
lark-cli docs +create --title "空白画板示例" --markdown '<whiteboard type="blank"></whiteboard>'
```
```html
<whiteboard type="blank"></whiteboard>
```
一次创建多个空白画板时,在同一个 markdown 里重复多个标签:
```html
<whiteboard type="blank"></whiteboard>
<whiteboard type="blank"></whiteboard>
```
#### 读取画板
读取时返回 `<whiteboard>` 标签:
```html
<whiteboard token="xxx" align="center" width="800" height="600"/>
```
**重要说明**
- 创建空白画板时,直接使用 `<whiteboard type="blank"></whiteboard>`
- 读取时只能获取 token可通过 media-download 查看内容,无法直接读出画板内部内容
- 画板编辑:详见 [../../lark-whiteboard/SKILL.md](../../lark-whiteboard/SKILL.md)
### 多维表格Base
```html
<bitable view="table"/>
<bitable view="kanban"/>
```
**属性**: view (table/kanban默认 table)
**注意**: token 是只读属性,创建时不能指定。只能创建空的多维表格,创建后再手动添加数据。
### 会话卡片ChatCard
```html
<chat-card id="oc_xxx" align="center"/>
```
**属性**: id (格式 oc_xxx, 必需), align (left/center/right)
### 内嵌网页Iframe
```html
<iframe url="https://example.com/survey?id=123" type="12"/>
```
**属性**: url (必需), type (组件类型数字, 必需)
**type 枚举**: 1=Bilibili, 2=西瓜, 3=优酷, 4=Airtable, 5=百度地图, 6=高德地图, 8=Figma, 9=墨刀, 10=Canva, 11=CodePen, 12=飞书问卷, 13=金数据
**重要提示**: 仅支持上述列出的网页类型。对于普通网页链接,请使用 Markdown 链接格式 `[链接文字](URL)` 代替。
### 链接预览LinkPreview
```html
<link-preview url="消息链接" type="message"/>
```
目前仅支持消息链接,只支持读取,不支持创建
### 引用容器QuoteContainer
```html
<quote-container>
引用容器内容
</quote-container>
```
与 quote 引用块不同,引用容器是容器类型,可包含多个子块
---
## 高级功能块
### 电子表格Sheet
```html
<sheet rows="5" cols="5"/>
<sheet/>
```
**属性**: rows (行数,默认 3最大 9), cols (列数,默认 3)
**注意**: token 是只读属性,创建时不能指定。只能创建空的电子表格,创建后使用 Sheet API 操作数据。
### 只读块类型
以下块类型仅支持读取,不支持创建:
| 块类型 | 标签 | 说明 |
|--------|------|------|
| 思维笔记 | `<mindnote token="xxx"/>` | 仅获取占位信息 |
| 流程图/UML | `<diagram type="1"/>` | type: 1=流程图, 2=UML |
| AI 模板 | `<ai-template/>` | 无内容占位块 |
### 任务块
```html
<task task-id="xxx" members="ou_123, ou_456" due="2025-01-01">任务标题</task>
```
### 同步块
```html
<!-- 源同步块 -->
<source-synced align="1">子块内容...</source-synced>
<!-- 引用同步块 -->
<reference-synced source-block-id="xxx" source-document-id="yyy">源内容...</reference-synced>
```
### 文档小组件AddOns
```html
<add-ons component-type-id="blk_xxx" record='{"key":"value"}'/>
```
### Wiki 子页面列表SubPageList
```html
<sub-page-list wiki="wiki_xxx"/>
```
仅支持知识库文档创建,需传入当前页面的 wiki token
### 议程Agenda
```html
<agenda>
<agenda-item>
<agenda-title>议程标题</agenda-title>
<agenda-content>议程内容</agenda-content>
</agenda-item>
</agenda>
```
### OKR 系列
```html
<okr id="okr_xxx">
<objective id="obj_1">
<kr id="kr_1"/>
</objective>
</okr>
```
仅支持 user_access_token 创建,需使用 OKR API 进行详细操作
---
## 提及和引用
### 提及用户
```html
<mention-user id="ou_xxx"/>
```
**属性**: id (用户 open_id格式 ou_xxx)
注意不要直接在文档中写 `@张三` 这类格式,应当使用 search-user 获取用户的 id并使用 `mention-user`。
### 提及文档
```html
<mention-doc token="doxcnXXX" type="docx">文档标题</mention-doc>
```
**属性**: token (文档 token), type (docx/sheet/bitable)
---
## 日期和时间
### 日期提醒Reminder
```html
<reminder date="2025-12-31T18:00+08:00" notify="true" user-id="ou_xxx"/>
```
**属性**:
- date (必需): `YYYY-MM-DDTHH:mm+HH:MM`, ISO 8601 带时区偏移
- notify (true/false): 是否发送通知
- user-id (必需): 创建者用户 ID
---
## 数学表达式
### 块级公式LaTeX
````markdown
$$
\int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$
````
### 行内公式
```markdown
爱因斯坦方程:$E = mc^2$(注意 $ 前后需空格,紧邻位置不能有空格)
```
---
## 写作指南
### 场景速查
| 场景 | 推荐组件 | 说明 |
|------|----------|------|
| 重点提示/警告 | Callout | 蓝色提示、黄色警告、红色危险 |
| 对比/并列展示 | Grid 分栏 | 2-3 列最佳,配合 Callout 更醒目 |
| 数据汇总 | 表格 | 简单用 Markdown复杂嵌套用 lark-table |
| 步骤说明 | 有序列表 | 可嵌套子步骤 |
| 时间线/版本 | 有序列表 + 加粗日期 | 适合里程碑、版本记录 |
| 代码展示 | 代码块 | 标注语言,适当添加注释 |
| 知识卡片 | Callout + emoji | 用于概念解释、小贴士 |
| 引用说明 | 引用块 > | 引用原文、名言 |
| 术语对照 | 两列表格 | 中英文、缩写全称等 |
| 架构/流程/组织/时间线/因果 | **空白画板** | 主动插入,用 lark-whiteboard 绘制(用户明确仅文本或数据密集表格场景除外) |
---
| 参数 | 必填 | 说明 |
| ------------------- | -- |---------------------------------------------|
| `--api-version` | 是 | 固定传 `v2` |
| `--content` | | 文档内容XML 或 Markdown 格式) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时 |
| `--parent-token` | 否 | 父文件夹或知识库节点 token`--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
## 最佳实践
- **空行分隔**:不同块类型之间用空行分隔
- **转义字符**:只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3``version~1.0``final_trajectory` 这类普通文本通常不需要转义
- **图片**:使用 URL系统自动下载上传
- **分栏**:列宽总和必须为 100
- **表格选择**:简单数据用 Markdown复杂嵌套用 `<lark-table>`
- **提及**@用户用 `<mention-user>`@文档用 `<mention-doc>`
- **目录**:飞书自动生成,无需手动添加
- 文档标题从内容中自动提取XML `<title>` 或 Markdown `#`),不要在内容开头重复写标题
- 创建较长的文档时,先创建基础内容,再用 `docs +update --command block_insert_after` 分段追加
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档
- [lark-doc-update](lark-doc-update.md) — 更新文档
- [lark-doc-media-insert](lark-doc-media-insert.md) — 插入图片/文件到文档
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -6,110 +6,129 @@
## 命令
```bash
# 获取文档内容(默认输出 Markdown 文本
lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1FjxxxxxxxxxxxxxxxxxxxtnAc"
# 获取文档(默认 XMLsimple
lark-cli docs +fetch --api-version v2 --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
# 直接传 token
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc
# Markdown 格式
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --doc-format markdown
# 知识库 URL 也支持
lark-cli docs +fetch --doc "https://xxx.feishu.cn/wiki/Z1FjxxxxxxxxxxxxxxxxxxxtnAc"
# 带 block ID用于后续 block 级更新)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --detail with-ids
# 分页获取(大文档)
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --offset 0 --limit 50
# 只拿目录
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --scope outline --max-depth 3
# 人类可读格式输出
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
# 按 block id 区间精读
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope range --start-block-id blkA --end-block-id blkB --detail with-ids
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope section --start-block-id <标题id> --detail with-ids
# 按关键词定位(多关键词用 | 分隔,任一命中即返回)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope keyword --keyword "部署|发布|上线"
```
## 选 `--detail`(每块详细度)
| 意图 | `--detail` | 说明 |
|------|-----------|------|
| **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown不含 block ID、样式属性、引用元数据 |
| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID`<p id="blkcnXXXX">`),可用于 `+update``--block-id` |
| **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 |
## 选 `--scope`(读取范围)
`--scope``--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。**
| 模式 | 何时用 | 关键参数 | 行为要点 |
|-|-|-|-|
| `outline` | 不知道结构,先看目录 | `--max-depth`(标题层级上限) | 扁平列出所有标题,**包括嵌在容器里的内嵌标题**(如 callout 里的 h3这些 id 可直接作后续 `section` / `range` 端点 |
| `section` | 读某个标题对应的整节 | `--start-block-id`(必填) | 顶层标题 → 展开到下一同级/更高级标题前;容器内节点(含内嵌标题) → 按"最小包容单元"返回容器/表格切片,不做 heading 扩展;顶层非标题块 → 仅该块 |
| `range` | 已知精确起止 | `--start-block-id` / `--end-block-id` 至少一个;`-1` = 读到末尾 | 两端同顶层 → 顶层序列切片;两端同一容器 → 容器整体;两端同一表格 → 瘦身切片;**跨顶层 → 端点所在顶层块整块输出,不做瘦身** |
| `keyword` | 只有模糊关键词 | `--keyword`(不区分大小写、子串,`\|` 分隔多词 OR | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
**设置 `--scope` 时共用** `--context-before` / `--context-after` / `--max-depth`
- `--max-depth``outline` = 标题层级上限3 = h1~h3其它模式 = 被选块的子树遍历深度(`-1` 不限,`0` 仅块自身)。
- `--context-before/--context-after`**只对整块顶层单元生效**;命中落在容器/表格内(返回容器或切片)时 before/after 被忽略,需要更大范围改用 `section` / `range` 显式指定。
**决策顺序**(核心原则:**局部获取优于全量获取**,能精确到节/区间就绝不全量拉取;**任何文档的第一次读取都应从 `outline` 开始**
1. **第一次接触文档 / 不知道结构** → 先 `outline` 探测目录(**强制首步,无论文档是"目标"还是"引用源"**),再回到 2/3 精读
2. 改/读某个**标题对应的整节** → `section`(最省心,**首选精读方式**
3. 精确自定义起止 / 跨节连续区间 → `range`
4. 只有模糊关键词 → `keyword`
5. **兜底**:确实需要整篇文档时才不传 `--scope`(默认整篇);**不要为了省事就读整篇**,局部模式上下文更省、响应更快
**推荐双步流程**`outline --max-depth 3` 拿目录 → `section --start-block-id <标题id> --detail with-ids` 精读该节。
## 局部读取的输出结构:`<fragment>` 与 `<excerpt>`
设置 `--scope` 时返回的 `content` 被一个 `<fragment>` 节点包裹,属性包含 `mode` / `requested-start` / `requested-end` / `keyword`(按需)。子节点只有两种形态:
- **顶层块**:完整块直接作为 `<fragment>` 的子节点,无额外包裹。
- **`<excerpt top-block-id="..." parent-block-path="...">`**:非顶层节选(容器整体 / 表格瘦身切片)。
- `top-block-id`:所在顶层块 id想看该块全貌时作 `section` / `range` 锚点再拉一次。
- `parent-block-path`:从顶层块到 excerpt 内容直接父节点的 id 路径,`/` 分隔(表格切片时即表格自身 id
**看到 `<excerpt>` 即意味着这是节选**,不能假设看到了该顶层块的全貌。
**表格默认瘦身**:即便 `<table>` 本身是顶层块也只返回 thead + 命中 tr。想拿整张表 → `range --start-block-id <table-id> --end-block-id <table-id>`;切片范围恰好覆盖全部 tr 时 SDK 自动升级为整块、不包 `<excerpt>`
## 返回值
```json
{
"ok": true,
"identity": "user",
"data": {
"document": {
"document_id": "doxcnXXXX",
"revision_id": 12,
"content": "<title>标题</title><p>文档内容...</p>"
}
}
}
```
`content` 的格式由 `--doc-format` 决定。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--doc` | 是 | 文档 URL 或 token支持 `/docx/``/wiki/` 链接,系统自动提取 token |
| `--offset` | | 分页偏移 |
| `--limit` | 否 | 分页大小 |
| `--format` | 否 | 输出格式json默认含 title、markdown、has_more 等字段) \| pretty |
| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | | 文档 URL 或 token支持 `/docx/``/wiki/` |
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` |
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
| `--start-block-id` | 否 | `range`/`section` 起始/锚点 id`section` 必填) |
| `--end-block-id` | 否 | `range` 结束 id`-1` 表示读到末尾 |
| `--keyword` | 否 | `keyword` 模式关键词;`\|` 分隔多词 OR |
| `--context-before` | 否 | 命中前拉几个兄弟块(仅对顶层单元生效,默认 `0` |
| `--context-after` | 否 | 命中后拉几个兄弟块(仅对顶层单元生效,默认 `0` |
| `--max-depth` | 否 | `outline` = 标题层级上限;其它 = 子树深度(`-1` 不限,默认) |
| `--format` | 否 | `json`(默认)\| `pretty` |
## 重要:图片、文件、画板的处理
## 图片、文件、画板的处理
**文档中的图片、文件、画板需要通过独立的 media shortcut 单独获取。**
**文档中的素材以 XML 标签形式出现:**
### 识别格式
返回的 Markdown 中,媒体文件以 HTML 标签形式出现:
- **图片**
```html
<image token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc" width="1833" height="2491" align="center"/>
```
- **文件**
```html
<view type="1">
<file token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc" name="skills.zip"/>
</view>
```
- **画板**
```html
<whiteboard token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc"/>
```
- 画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
### 获取步骤
1. 从 HTML 标签中提取 `token` 属性值
2. 如果目标是图片/文件素材,且用户只是想查看/预览,调用 [`lark-doc-media-preview`](lark-doc-media-preview.md)`docs +media-preview`
```bash
lark-cli docs +media-preview --token "提取的token" --output ./preview_media
```
3. 如果用户明确要下载,或目标是 `<whiteboard token="..."/>`,调用 [`lark-doc-media-download`](lark-doc-media-download.md)`docs +media-download`
```bash
lark-cli docs +media-download --token "提取的token" --output ./downloaded_media
```
## Wiki URL 处理策略
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。当不确定类型时,**不能直接假设是云文档**,必须先查询实际类型。
### 处理流程
1. **先调用 lark-wiki 解析 wiki token**
2. **从返回的 `node` 中获取 `obj_type`(实际文档类型)和 `obj_token`(实际文档 token**
3. **根据 `obj_type` 调用对应工具**
| obj_type | 工具 | 说明 |
|----------|------|------|
| `docx` | `lark-doc-fetch` | 云文档 |
| `sheet` | `lark-sheet` | 电子表格 |
| `bitable` | `lark-base` | 多维表格 |
| 其他 | 告知用户暂不支持 | — |
## 重要任务卡片task 标签)
`docs +fetch` 默认不会查询/展开文档中内嵌的任务详情(例如任务标题、状态、负责人等)。
它会在返回的 Markdown 中保留任务引用,并返回任务 IDGUID例如
```html
<task task-id="30597dc9-262e-4597-97f4-f8efcd1aeb95"></task>
```xml
<img token="..." url="https://..." width="..." height="..."/>
<source token="..." url="https://..." name="skills.zip"/>
<whiteboard token="..."/>
```
如果用户需要查看该任务的详情,需要用返回的 `task-id` 再调用任务 CLI 查询:
- `<img>` / `<source>``url` 时,直接用该 URL 下载即可(普通 HTTP GET无需走 shortcut。
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut`docs +media-download --token <token> --output ./downloaded_media`
```bash
lark-cli task tasks get --as user --params '{"task_guid":"30597dc9-262e-4597-97f4-f8efcd1aeb95"}'
```
## 嵌入电子表格 / 多维表格
## 工具组合
| 需求 | 工具 |
|------|------|
| 获取文档文本 | `docs +fetch` |
| 预览图片/文件素材 | `docs +media-preview` |
| 下载图片/文件/画板 | `docs +media-download` |
| 创建新文档 | `docs +create` |
| 更新文档内容 | `docs +update` |
返回中可能含 `<sheet>``<bitable>``<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
## 参考

View File

@@ -0,0 +1,61 @@
# Markdown 格式参考
`docs +fetch / +create / +update` 使用 `--doc-format markdown` 时适用。
## 转义规则
> **⚠️ 当文本中包含以下字符且**不想**触发 Markdown 语法时,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。
### 无条件转义(行内生效,任何位置都要转义)
| 符号 | Markdown 语法用途 | 转义写法 | 示例 |
|------|-------------------|----------|------|
| `\` | 转义符本身 | `\\` | `C:\\Users` → C:\Users |
| `` ` `` | 行内代码 | `` \` `` | `` 用 \` 包裹 `` |
| `*` | 斜体 / 加粗 | `\*` | `3 \* 5 = 15` → 3 \* 5 = 15 |
| `_` | 斜体 / 加粗 | `\_` | `foo\_bar\_baz` → foo\_bar\_baz |
| `[` `]` | 链接文本 | `\[` `\]` | `\[非链接\]` |
| `$` | 数学公式定界 | `\$` | `价格 \$100` |
| `~` | 删除线GFM `~~text~~` | `\~` | `a\~\~b\~\~c` → a~~b~~c |
### 位置敏感转义(仅在特定位置才需要转义)
| 符号 | Markdown 语法用途 | 转义条件 | 示例 |
|------|-------------------|----------|------|
| `#` | 标题 | **仅行首**(去除前导空白后)| 行首 `\# 这不是标题`;行内 `A # B` 无需转义 |
| `+` | 无序列表 | **仅行首**(去除前导空白后)| 行首 `\+ item`;行内 `1 + 2` 无需转义 |
| `-` | 无序列表 / 分隔线 | **仅行首**(去除前导空白后)| 行首 `\- item`;行内 `A - B` 无需转义 |
| `>` | 引用块 | **仅行首**(去除前导空白后)| 行首 `\> 不是引用`;行内 `a > b` 无需转义 |
| `\|` | 表格 cell 分隔 | **仅在 GFM 表格 cell 内** | cell 内 `A \| B`;行内普通文本 `a \| b` 无需转义 |
**不需要转义的场景:**
- 在 `` ` `` 行内代码或 ` ``` ` 代码块内,所有符号均为字面量,无需转义
- `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响
**导出已转义,不要反转义:**
`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[``\|``\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
**写入时必须转义:**
使用 `docs +create``docs +update``--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。
**导出 → 更新 工作流示例:**
1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
2.`str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`
自行构造 Markdown 内容写入时同理:如字面文本 `a]b` 应写为 `a\]b``C:\Users` 应写为 `C:\\Users`
## 图片语法
Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下载:
```markdown
![alt text](https://example.com/photo.png)
```
- `alt text` 为图片描述(可选,可留空)
- URL 支持 `http://``https://` 协议
- 对应的 XML 格式为:`<img href="https://example.com/photo.png"/>`
## Markdown 不支持的 Block 类型
非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。

View File

@@ -8,6 +8,11 @@
## 命令
```bash
# 除了上传本地文件,还可以在 `docs +update` 时直接通过网络 URL 插入图片,无需先下载到本地:
lak-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "目标 block_id" \
--content '<img href="https://example.com/photo.png"/>'
# 插入图片(默认)
lark-cli docs +media-insert --doc doxcnXXX --file ./image.png

View File

@@ -1,264 +1,218 @@
# docs +update更新飞书云文档
> **前置条件** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证、全局参数和安全规则
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 3. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
> 4. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
更新飞书云文档内容,支持 7 种更新模式。优先使用局部更新replace_range/append/insert_before/insert_after慎用 overwrite会清空文档重写可能丢失图片、评论等
通过八种指令精确更新飞书云文档。支持字符串级别和 block 级别的操作
## 重要说明
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
## 命令
```bash
# 追加内容
lark-cli docs +update --doc "<doc_id_or_url>" --mode append --markdown "## 新章节\n\n追加内容"
# 定位替换(内容定位)
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-with-ellipsis "旧标题...旧结尾" --markdown "## 新内容"
# 定位替换(标题定位)
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n新内容"
# 全文替换
lark-cli docs +update --doc "<doc_id>" --mode replace_all --selection-with-ellipsis "张三" --markdown "李四"
# 前插入
lark-cli docs +update --doc "<doc_id>" --mode insert_before --selection-with-ellipsis "## 危险操作" --markdown "> 警告:以下需谨慎!"
# 后插入
lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-ellipsis "代码示例" --markdown "**输出示例**result = 42"
# 删除内容
lark-cli docs +update --doc "<doc_id>" --mode delete_range --selection-by-title "## 废弃章节"
# 覆盖(慎用)
lark-cli docs +update --doc "<doc_id>" --mode overwrite --markdown "# 全新内容"
# 同时更新标题
lark-cli docs +update --doc "<doc_id>" --mode append --markdown "## 更新日志" --new-title "文档 v2.0"
# 在指定内容后新增两个空白画板
lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-ellipsis "有序列表" --markdown $'<whiteboard type="blank"></whiteboard>\n<whiteboard type="blank"></whiteboard>'
```
> **⚠️ 格式选择规则:始终使用 XML 格式(默认),除非用户明确要求使用 Markdown。** 不要因为 Markdown 写起来更简单就自行切换为 Markdown。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | 是 | 文档 URL 或 token |
| `--mode` | 是 | 更新模式(见下方 7 种模式说明 |
| `--markdown` | 视模式 | 内容Lark-flavored Markdown。delete_range 模式不需要,其他模式必填。若要新增空白画板,直接传 `<whiteboard type="blank"></whiteboard>`;需要多个画板时,在同一个 markdown 里重复多个标签 |
| `--selection-with-ellipsis` | 视模式 | 内容定位(如 `"开头...结尾"`)。与 `--selection-by-title` 互斥 |
| `--selection-by-title` | 视模式 | 标题定位(如 `"## 章节名"`)。与 `--selection-with-ellipsis` 互斥 |
| `--new-title` | 否 | 同时更新文档标题 |
| `--command` | 是 | 操作指令(见下方指令速查表 |
| `--doc-format` | | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容 |
| `--pattern` | 视指令 | 匹配文本str_replace / str_delete |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID逗号分隔用于 block_copy_insert_after / block_move_after |
| `--revision-id` | 否 | 基准版本号,-1 = 最新(默认 `-1` |
# 定位方式
## 指令速查表
定位模式replace_range/replace_all/insert_before/insert_after/delete_range支持两种定位方式二选一
| 指令 | 说明 | 必需参数 |
|------|------|----------|
| `str_replace` | 全文文本查找替换replacement 支持富文本标签) | `--pattern` `--content` |
| `str_delete` | 全文文本查找删除 | `--pattern` |
| `block_insert_after` | 在指定 block 之后插入新内容 | `--block-id` `--content` |
| `block_copy_insert_after` | 复制源 block 并插入到锚点之后(源块不变) | `--block-id` `--src-block-ids` |
| `block_replace` | 替换指定 block同一 block 仅限一次) | `--block-id` `--content` |
| `block_delete` | 删除指定 block逗号分隔可批量 | `--block-id` |
| `overwrite` | ⚠️ 清空文档后全文重写(可能丢失图片、评论) | `--content` |
| `append` | 在文档末尾追加内容(等价于 `block_insert_after --block-id -1` | `--content` |
| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` + (`--content``--src-block-ids`) |
## selection-with-ellipsis - 内容定位
## 指令示例
支持两种格式:
### str_replace — 全文文本替换
1. **范围匹配**`开头内容...结尾内容`
- 匹配从开头到结尾的所有内容(包含中间内容)
- 建议 10-20 字符确保唯一性
```bash
# 简单文本替换
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "张三" --content "李四"
2. **精确匹配**`完整内容`(不含 `...`
- 匹配完整的文本内容
- 适合替换短文本、关键词等
# 替换为富文本(加粗 + 链接)
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "旧链接" --content '<b>新链接</b> <a href="https://example.com">点击查看</a>'
**转义说明**:如果要匹配的内容本身包含 `...`,使用 `\.\.\.` 表示字面量的三个点。
# 仅当用户明确要求时才使用 Markdown
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--doc-format markdown --pattern "旧内容" --content "新内容"
```
示例:
- `你好...世界` → 匹配从"你好"到"世界"之间的任意内容
- `你好\.\.\.世界` → 匹配字面量 "你好...世界"
### str_delete — 全文文本删除
**建议**:如果文档中有多个 `...`,建议使用更长的上下文来精确定位,避免歧义。
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_delete \
--pattern "废弃的内容"
```
## selection-by-title - 标题定位
### block_insert_after — 在指定 block 之后插入
格式:`## 章节标题`(可带或不带 # 前缀)
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "目标 block_id" \
--content '<h2>新章节</h2><ul><li>要点 1</li><li>要点 2</li></ul>'
```
自动定位整个章节(从该标题到下一个同级或更高级标题之前)。
### block_replace — 替换指定 block
**示例**
- `## 功能说明` → 定位二级标题"功能说明"及其下所有内容
- `功能说明` → 定位任意级别的"功能说明"标题及其内容
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \
--block-id "目标 block_id" \
--content '<p>替换后的段落内容</p>'
```
# 可选参数
### block_delete — 删除指定 block
## new-title
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_delete \
--block-id "目标 block_id"
```
更新文档标题。如果提供此参数,将在更新文档内容后同步更新文档标题。
### overwrite — 全文覆盖
**特性**
- 仅支持纯文本,不支持富文本格式
- 长度限制1-800 字符
- 可以与任何 mode 配合使用
- 标题更新在内容更新之后执行
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command overwrite \
--content '<title>全新文档</title><h1>概述</h1><p>新的内容</p>'
```
# 返回值
> ⚠️ 会清空文档后重写,可能丢失图片、评论等。仅在需要完全重建文档时使用。
## 成功
### append — 在文档末尾追加
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command append \
--content '<h2>新增章节</h2><p>追加的内容</p>'
```
> 等价于 `block_insert_after --block-id -1`,无需先获取 block ID。
### block_copy_insert_after — 复制块并插入
将一个或多个源块复制到锚点块之后,源块保持不变。`--src-block-ids` 为逗号分隔的源块 ID按顺序依次插入到锚点之后。
```bash
# 复制多个块按顺序插入anchor → a → b → c
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_copy_insert_after \
--block-id "锚点 block_id" \
--src-block-ids "block_a,block_b,block_c"
```
### block_move_after — 移动已有 block
将文档中已有的 block 移动到指定锚点之后。使用 `--src-block-ids` 指定要移动的块 ID无需 `--content`
```bash
# 移动到页面末尾
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_move_after \
--block-id "-1表示末尾page_id表示开头blk" \
--src-block-ids "block_a,block_b"
```
## 返回值
```json
{
"success": true,
"doc_id": "文档ID",
"mode": "使用的模式",
"board_tokens": ["可选:新建画板 token 列表"],
"message": "文档更新成功xxx模式",
"warnings": ["可选警告列表"],
"log_id": "请求日志ID"
"ok": true,
"identity": "user",
"data": {
"document": {
"revision_id": 13,
"newblocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "token": "boardXXXX" }
]
},
"result": "success",
"updated_blocks_count": 3,
"warnings": []
}
}
```
如果本次 `docs +update` 创建了画板,响应会额外返回 `board_tokens`。在 CLI 的成功 JSON 输出里,后续编辑画板应读取 `data.board_tokens`
| 字段 | 说明 |
|------|------|
| `result` | `success` \| `partial_success` \| `failed` |
| `updated_blocks_count` | 实际更新的 block 数量 |
| `warnings` | 警告信息列表 |
| `document.newblocks` | 本次操作新增的 block 列表(如画板),可从中提取 `token` 用于后续编辑 |
## 异步模式(大文档超时)
## 典型工作流
```json
{
"task_id": "async_task_xxxx",
"message": "文档更新已提交异步处理,请使用 task_id 查询状态",
"log_id": "请求日志ID"
}
```
### 精确 block 级更新
## 错误
1. **获取文档内容和 block ID**
```bash
lark-cli docs +fetch --api-version v2 --doc "<doc_id>" --detail with-ids
```
```json
{
"error": "[错误码] 错误消息\n💡 Suggestion: 修复建议\n📍 Context: 上下文信息",
"log_id": "请求日志ID"
}
```
2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
---
3. **执行更新**
```bash
# 替换特定 block
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \
--block-id "blkcnXXXX" --content "<p>新内容</p>"
# 使用示例
# 在某 block 后插入
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "blkcnXXXX" --content "<h2>追加的章节</h2>"
```
## append - 追加到末尾
### 简单文本替换
不需要 block ID直接匹配替换
```bash
lark-cli docs +update --doc "文档ID或URL" --mode append --markdown "## 新章节\n\n追加的内容..."
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "v1.0" --content "v2.0"
```
## replace_range - 定位替换
## 画板处理
使用 `--selection-with-ellipsis`
```bash
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-with-ellipsis "## 旧标题...旧结尾。" --markdown "## 新标题\n\n新的内容..."
```
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
使用 `--selection-by-title`(替换整个章节):
```bash
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n更新后的内容..."
```
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
## replace_all - 全文替换
## 最佳实践
```bash
lark-cli docs +update --doc "文档ID" --mode replace_all --selection-with-ellipsis "张三" --markdown "李四"
```
返回值包含 `replace_count` 字段,表示替换的次数。
**注意**
-`replace_range` 不同,`replace_all` 允许多个匹配
- 如果没有找到匹配内容,会返回错误
- `--markdown` 可以为空字符串,表示删除所有匹配内容
## delete_range - 删除内容
```bash
lark-cli docs +update --doc "文档ID" --mode delete_range --selection-by-title "## 废弃章节"
```
注意delete_range 模式不需要 `--markdown` 参数。
## overwrite - 完全覆盖
⚠️ 会清空文档后重写,可能丢失图片、评论等,仅在需要完全重建文档时使用。
```bash
lark-cli docs +update --doc "文档ID" --mode overwrite --markdown "# 新文档\n\n全新的内容..."
```
## 创建空白画板
当用户要“新增空白画板”时,不要用 Mermaid 占位图;直接按 whiteboard 标签传 `--markdown`
自然语言请求示例:
- “给我在这个文档末尾新增一个空白画板”
```bash
# 追加一个空白画板
lark-cli docs +update --doc "文档ID" --mode append --markdown '<whiteboard type="blank"></whiteboard>'
# 在指定内容后新增两个空白画板
lark-cli docs +update --doc "文档ID" --mode insert_after --selection-with-ellipsis "有序列表" --markdown $'<whiteboard type="blank"></whiteboard>\n<whiteboard type="blank"></whiteboard>'
```
成功后,响应里的 `data.board_tokens` 就是新建画板的 token 列表;如果后续要继续编辑这些画板,直接使用这些 token。
---
# 最佳实践
## 重要:画板编辑
> **⚠️ docs +update 不能编辑已有画板内容,但可以创建新的空白画板**
画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
## 小粒度精确替换
修改文档内容时,**定位范围越小越安全**。尤其是表格、分栏等嵌套块,应精确定位到需要修改的文本,避免影响其他内容。
## 保护不可重建的内容
图片、画板、电子表格、多维表格、任务等内容以 token 形式存储,**无法读出后原样写入**。
**保护策略**
- 替换时避开包含这些内容的区域
- 精确定位到纯文本部分进行修改
## 分步更新优于整体覆盖
修改多处内容时:
- ✅ 多次小范围替换,逐步修改
- ⚠️ 谨慎使用 `overwrite` 重写整个文档,除非你认为风险完全可控
**原因**:局部更新保留原有媒体、评论、协作历史,更安全可靠。
## insert 模式扩大定位范围时注意插入位置
使用 `insert_before``insert_after` 时,如果目标内容重复出现,需要扩大 `--selection-with-ellipsis` 范围来唯一定位。
**关键**:插入位置基于匹配范围的**边界**
- `insert_after` → 插入在匹配范围的**结尾**之后
- `insert_before` → 插入在匹配范围的**开头**之前
## 修复画板语法错误
`docs +create``docs +update` 返回画板写入失败的 warning 时:
1. warning 中包含 whiteboard 标签(如 `<whiteboard token="xxx"/>`
2. 分析错误信息,修正 Mermaid/PlantUML 语法
3.`--mode replace_range` 替换:`--selection-with-ellipsis` 使用 warning 中的 whiteboard 标签,`--markdown` 提供修正后的代码块
4. 重新提交验证
---
# 注意事项
- **Markdown 语法**:支持飞书扩展语法,详见 [lark-doc-create](lark-doc-create.md) 工具文档
- **精确操作优于全文覆盖**:使用 `block_replace`/`block_insert_after` 精确修改,避免 `overwrite` 全文覆盖
- **str_replace 适用于行内文本,容器操作优先用 block_replace**`str_replace` 只建议用来替换段落内/行内的短文本片段;一旦涉及整段、整块或容器级(列表、表格、分栏、引用块等)改动,应优先用 `block_replace` 指定 block_id 重建该容器,避免 `str_replace` 跨 block 匹配带来的副作用
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
2. 用 `block_delete` 批量删除旧的 block
3. 这样可以保留文档中其他不相关的内容(图片、评论等)
- **视觉丰富度**:插入或替换内容时,同样遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档
- [lark-doc-create](lark-doc-create.md) — 创建文档(含完整 Markdown 格式参考
- [lark-doc-media-insert](lark-doc-media-insert.md) — 插入图片/文件到文档
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,169 @@
基于 HTML 子集的 XML 格式描述飞书文档内容。
# 一、标准 HTML 标签
p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr, img, b, em, u, del, a, br, span 语义不变
# 二、扩展标签速查表
## 块级标签
|标签|说明|关键属性|
|-|-|-|
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
## 容器标签
|标签|说明|关键属性|
|-|-|-|
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
| `<whiteboard>` | 嵌入画板 | `type`: `mermaid` \| `plantuml` \| `blank` |
| `<pre>` | (代码块,内含 `code`| `lang`, `caption` |
| `<figure>` | 视图容器 | `view-type` |
| `<bookmark>` | 书签链接 | `<bookmark name="标题" href="https://..."></bookmark>`,必传 name 和 href |
## 行内组件
| 标签 | 说明 | 关键属性 |
|-|-|-|
| `<cite type="user">` | @人 | `<cite type="user" user-id="userID"></cite>` |
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
| `<source>` | 文件附件(可独立成块或内联) | `<source name="报告.pdf"/>` |
| `<a type="url-preview">` | 预览卡片 | `<a type="url-preview" href="...">标题</a>` |
| `<button>` | 操作按钮 | `background-color`,src,必须包含 `action=OpenLink|DuplicatePage|FollowPage` |
| `<time>` | 提醒 | `必包含 expire-time、notify-time=毫秒时间戳、should-notify=true|false` |
## 文本块通用属性
- `align``"left"`|`"center"`|`"right"`(适用于 p / h1-h9 / li / checkbox
- 有序列表项用 `seq="auto"` 自动编号
# 三、资源块
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>``<whiteboard type="blank"></whiteboard>` 空白;`<whiteboard type="mermaid|plantuml">内容</whiteboard>` 带内容;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
# 四、块级复制与移动
## 移动block_move_after
支持**所有**块类型(块级标签、容器标签、行内组件、资源块),使用 `docs +update --command block_move_after --block-id "<锚点>" --src-block-ids "id1,id2"`
## 复制block_copy_insert_after
- **基础标签**(块级标签、容器标签、行内组件):均支持复制
- **资源块**:仅 img、source、whiteboard、sheet、chat_card 支持复制task、bitable、base_ref、synced_reference、synced_source、okr 不支持复制
使用 `docs +update --command block_copy_insert_after --block-id "<锚点>" --src-block-ids "id1,id2"`
> 详见 [lark-doc-update.md](lark-doc-update.md)。
# 五、补充规则
## 富文本样式嵌套顺序
- 行内样式标签必须按以下固定顺序嵌套(外 → 内),关闭顺序严格反转:`<a> → <b> → <em> → <del> → <u> → <code> → <span> → 文本内容`
## 列表分组
- 连续同类型列表项自动合并为一个 `<ul>``<ol>`
- 嵌套子列表放在 `<li>` 内部
- 新增列表项必须包在 `<ul>``<ol>` 内:
```xml
<ul>
<li>第一项</li>
<li>第二项</li>
</ul>
```
## 表格扩展
标准 HTML table 结构不变,扩展点:
- `<colgroup>` / `<col>` 定义列宽,紧跟 `<table>` 之后:`<col span="2" width="100"/>`
- `<th>` / `<td>` 增加 `background-color` 和 `vertical-align`top | middle | bottom
- 有表头时第一行在 `<thead>` 用 `<th>`,其余在 `<tbody>` 用 `<td>`
- 合并单元格仅起始格输出 `colspan` / `rowspan`,被合并的格不出现
# 六、美化系统
- 颜色优先使用命名色,也可写 `rgb(r,g,b)` / `rgba(r,g,b,a)`。**基础色7 色)**gray, red, orange, yellow, green, blue, purple
| 属性 | 支持的命名色 |
|-|-|
| 文字颜色 `<span text-color>` | 基础色 |
| 高亮框字色 `<callout text-color>` | 基础色 |
| 高亮框边框 `<callout border-color>` | 基础色 |
| 文字背景 `<span background-color>` | 基础色 + `light-{色}` + `medium-gray` |
| 高亮框填充 `<callout background-color>` | `gray` + `light-{色}` + `medium-{色}` |
| 单元格背景 `<th/td background-color>` | 同文字背景 |
| 按钮背景 `<button background-color>` | 同文字背景 |
- 常用 emoji 💡(默认)✅❌⚠️📝❓❗👍❤️📌🏁⭐
# 七、**重要规则**
## 转义规则:标签本身 **禁止转义**,只有标签内部的文本内容才需要转义
**错误** ❌:`&lt;p&gt;内容&lt;/p&gt;`(把标签也转义了)
**正确** ✅:`<p>A &amp; B 的对比1 &lt; 2</p>`(标签保持原样,文本中的 `&` 和 `<` 才转义)
转义字符表:
- `<` → `&lt;`
- `>` → `&gt;`
- `&` → `&amp;`
- `\n`(换行符) → `<br/>`
## 八、完整示例
```xml
<title>文档标题</title>
<h1>一级标题</h1>
<p><b>加粗文本</b><span text-color="green">绿色文本</span></p>
<callout emoji="💡" background-color="light-yellow" border-color="yellow">
<p>高亮框内容,子块仅支持文本/标题/列表/待办/引用</p>
</callout>
<checkbox done="true">已完成事项</checkbox>
<checkbox done="false">未完成事项</checkbox>
<grid>
<column width-ratio="0.5">
<p>左栏</p>
</column>
<column width-ratio="0.5">
<p>右栏</p>
</column>
</grid>
<table>
<colgroup><col span="2" width="120"/></colgroup>
<thead><tr><th background-color="light-gray">表头</th><th background-color="light-gray">表头</th></tr></thead>
<tbody><tr><td>单元格</td><td>单元格</td></tr></tbody>
</table>
<p><cite type="doc" doc-id="DOC_TOKEN"></cite> <cite type="user" user-id="USER_ID"></cite></p>
<ol><li seq="auto">第一项</li><li seq="auto">第二项</li></ol>
<p><a type="url-preview" href="https://example.com">链接标题</a></p>
<p><latex>E = mc^2</latex></p>
<pre lang="go" caption="示例"><code>fmt.Println("hello")</code></pre>
<hr/>
<source name="文件名.pdf"/>
<img src="IMG_TOKEN" width="800" height="400" caption="说明" name="图.png"/>
<img href="https://example.com/photo.png"/>
<button action="OpenLink" src="https://example.com">按钮文字</button>
<time expire-time="1775916000000" notify-time="1775912400000" should-notify="false">时间戳毫秒</time>
<cite type="citation"><a href="https://example.com">引文标题</a></cite>
<bookmark name="书签标题" href="https://example.com"></bookmark>
<task task-id="TASK_GUID"></task>
<chat_card chat-id="CHAT_ID"></chat_card>
```

View File

@@ -0,0 +1,50 @@
# 从零创作工作流
用户提供主题、需求或简要说明,需要生成一份新的飞书文档时,遵循本工作流。
## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
3. **Observe观察** — 检查命令输出,验证正确性,核查样式是否达标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
循环在文档达到质量标准且满足用户需求时结束。不要试图一次性产出完美内容——迭代打磨效果更好。根据用户实际需求灵活决定文档结构和版块,而不是套用固定模板。
## 典型 Code-Act Loop 流程
### 第一波 — 规划与骨架(串行)
1. 分析用户需求:受众、目的、范围
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block
3. `docs +create --api-version v2` 创建文档:标题 + 开头 `<callout>` + 骨架(各级标题 + 简短占位摘要)
### 第二波 — 内容撰写(并行 Agent
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、期望的 block 类型
- `lark-doc-xml.md``lark-doc-style.md` 的完整路径Agent 须先读取)
- 使用 `docs +update --command append``block_insert_after` 写入
### 第三波 — 整合审查 + 画板意图识别(串行)
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。记录需要插图的章节及推荐的画板类型
### 第四波 — 润色与图表(并行 Agent
8. Spawn Agent 定向改进:(结合 `lark-doc-style.md` 进润)
- **优先处理第三波识别出的画板需求**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
- 文字密集章节转为 `<table>`/`<grid>`/`<callout>`
- 主要章节间补充 `<hr/>`
- 本地图片使用 `docs +media-insert` 插入
## Agent 子任务要求
Spawn Agent 时必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
章节较多时,先 `docs +create` 建骨架,再分段 `append` 追加,比一次性超长 `--content` 更可靠。

View File

@@ -0,0 +1,97 @@
# 文档样式指南
创建或编辑文档时,必须遵循本指南,使用结构化 block 提升可读性和视觉层次。
## 一、核心原则
1. **结构优于文字**:能用结构化 block 表达的信息,不用纯文本段落
2. **Front-load 结论**:文档以 `<callout>` 开头概括核心结论;每章节首段点明要旨
3. **视觉节奏**:连续纯文本不超过 3 段;不同主题章节间用 `<hr/>` 分隔
4. **最少惊讶**:同类信息使用同类元素,全篇风格统一
## 二、元素选择指南
涉及图表需求时,简单图用 `<whiteboard type="mermaid/plantuml">` 内嵌,复杂图使用 **lark-whiteboard** skill。
| 场景 | 推荐方案 |
|-|-|
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
| 方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏 |
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
| 任务清单 / 检查项 | `<checkbox>` |
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 简单流程图 / 时序图 / 状态机 / 甘特图 | `<whiteboard type="mermaid/plantuml">` |
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | **lark-whiteboard** skill |
### 画板意图识别
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本:
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|-|-|-|
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
**判断规则:**
- 简单图(节点 ≤ 10、无需精细排版`<whiteboard type="mermaid/plantuml">` 内嵌
- 复杂图(节点 > 10、需自定义布局/样式、数据图表)→ spawn Agent 使用 **lark-whiteboard** skill
### 画板语法与插入
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
#### 内嵌 Mermaid / PlantUML首选
简单图直接用 `<whiteboard type="mermaid|plantuml">语法</whiteboard>`,作为 block 嵌入文档。
#### DSL 画板Mermaid / PlantUML 不够用时)
需要架构图、对比图、组织架构等复杂结构时:
1.`<whiteboard type="blank"></whiteboard>` 通过 `docs +create` / `docs +update` 插入空白画板
2. 从响应 `data.document.newblocks` 中提取画板 `token`
3. 切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 设计并上传 DSL
更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
## 三、颜色语义
全篇保持语义一致,同一语义必须使用同一颜色:
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|-|-|-|-|
| 信息、说明 | "说明:" | `light-blue` | `blue` |
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
| 中性、辅助 | — | `light-gray` | — |
- 表头统一 `background-color="light-gray"`
- 关键指标用 `<span text-color="green/red">` 突出,**必须同时用 ↑↓ 或 +/- 标注方向**(色觉无障碍)
## 四、排版规范
- 标题层级 ≤ 4 层,段落单段 ≤ 5 行,列表嵌套 ≤ 2 层Grid ≤ 3 列
- 文档开头用 `<callout>` front-load 结论;
## 五、丰富度自检
生成内容后必须自检,**未达标时主动优化**
| 指标 | 达标标准 |
|-|-|
| 富 block 密度 | ≥ 40%(非纯文本 block 数 ÷ 总 block 数) |
| 元素多样性 | ≥ 3 种不同 block 类型 |
| 连续纯文本 | ≤ 3 段连续 `<p>` |
| 章节丰富度 | 每 h1/h2 ≥ 1 个非纯文本 block |
| 开头 callout | 必须 |
| 视觉节奏 | 不同主题章节间有 `<hr/>` |

View File

@@ -0,0 +1,48 @@
# 改写增强工作流
用户提供已有文档链接或 token需要改写、润色、补充或重排版时遵循本工作流。
## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
3. **Observe观察** — 检查命令输出,验证正确性,核查样式是否达标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
## 核心原则:精准手术优于全量覆盖
1. **精准手术**:只改用户指定的 block不改其他 block。
2. **全量覆盖**:如果用户明确要改整篇,才用 `overwrite` 命令。
3. **保真约束**:改写时原文里的 `<cite type="user">`@人)、`<cite type="doc">`@文档)、`<img>``<source>``<whiteboard>``<sheet>``<bitable>``<synced_reference>` 等行内组件和资源块一律原样保留(含所有 token / user-id / doc-id 属性),不许替换成纯文本姓名、链接或占位符。
## 工作流程
### 第一波 — 分析 + 画板意图识别(串行)
1. **选择读取范围**(节省上下文的关键):
- 用户只改某一节 / 文档较大 → 先 `--scope outline --max-depth 2` 拿目录,再 `--scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id
- 需要精确跨节区间 → `--scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
- 用户只给了模糊关键词 → `--scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
2. 系统性评估:结构清晰度、富 block 密度≥40%、元素多样性≥3种、连续 `<p>` 是否超过 3 段、是否有开头 callout 和章节 `<hr/>`
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。记录需要插图的章节block ID及推荐的画板类型
4. 向用户简要说明改进计划(包含识别出的画板机会)
### 第二波 — 定向改写(并行 Agent
5. Spawn Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID`lark-doc-style.md`
- 开头适当添加 `<callout>`、重组引言
- 纯文本转为 `<grid>`/`<table>`/`<whiteboard>`
- **对第一波识别出的画板候选段落**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
- 添加流程图、对比分栏等富 block
### 第三波 — 验证(串行)
5. 获取更新后文档局部内容,重新检查样式指标
6. 未达标则定向修正,向用户呈现结果
## Agent 子任务要求
Spawn Agent 时必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
**上下文节省提示**Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。

View File

@@ -114,9 +114,9 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--selection-with-ellipsis` 或 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--selection-with-ellipsis` / `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 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 |
| 列出文档评论 | `file_token` | 同添加评论 |
@@ -124,8 +124,8 @@ Drive Folder (云空间文件夹)
### 评论能力边界(关键!)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--selection-with-ellipsis` / `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--selection-with-ellipsis` 或 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。
- 全文评论:未传 `--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` 获取。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。

View File

@@ -24,22 +24,22 @@ lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--content '[{"type":"text","text":"这里需要一段全文评论"}]'
# 给 docx 文档里匹配到的文字添加局部评论
# 给 docx 文档的指定 block 添加局部评论block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取)
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--selection-with-ellipsis "流程" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请补充流程说明"}]'
# wiki 链接也支持局部评论,但解析结果必须是 docx
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--selection-with-ellipsis "流程" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请补充更细的开发步骤"}]'
# 组合文本、@用户、链接元素
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--selection-with-ellipsis "流程" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
# 给电子表格单元格添加评论(--block-id 格式为 <sheetId>!<cell>
@@ -68,7 +68,7 @@ lark-cli drive +add-comment \
lark-cli drive +add-comment \
--doc "<DOCX_TOKEN>" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"评论内容"}]'
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
# 如果需要更底层的原生 API也可以直接调用 V2 协议
lark-cli schema drive.file.comments.create_v2
@@ -80,7 +80,7 @@ lark-cli drive file.comments create_v2 \
# 预览底层调用链
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--selection-with-ellipsis "流程" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请补充流程说明"}]' \
--dry-run
```
@@ -92,20 +92,21 @@ lark-cli drive +add-comment \
| `--doc` | 是 | 文档 URL / token、sheet URL或可解析到 `doc`/`docx`/`sheet` 的 wiki URL |
| `--type` | 裸 token 时必填 | 文档类型:`doc``docx``sheet`。URL 输入时自动识别,无需传 |
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论 |
| `--block-id` | 局部评论时必填 | 目标块 ID可通过 `docs +fetch --api-version v2 --detail with-ids` 获取 |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论(不适用于 sheet |
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥(不适用于 sheet |
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6` |
## 行为说明
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`)。此时 `--full-comment``--selection-with-ellipsis` 不可用。
- **无需预先获取文档内容**:使用 `--selection-with-ellipsis`shortcut 内部会自动调用 `locate-doc` 定位目标文本,不需要先调用 `docs +fetch` 获取文档。
- 未传 `--selection-with-ellipsis` / `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`
- 全文评论支持 `docx`、旧版 `doc` URL以及最终可解析为 `doc`/`docx` 的 wiki URL。
-`--selection-with-ellipsis``--block-id`shortcut 创建**局部评论(划词评论)**;该模式仅支持 `docx`,以及最终可解析为 `docx` 的 wiki URL。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式仅支持 `docx`,以及最终可解析为 `docx` 的 wiki URL。
- `--content` 接收结构化评论元素数组;`type` 支持 `text``mention_user``link`。为便于书写,`mention_user` / `link` 元素可以直接把用户 ID 或链接地址放在 `text` 字段中shortcut 会转换成 OpenAPI 所需字段。
- 局部评论走 `locate-doc` 时,内部固定使用 `limit=10`
-`locate-doc` 命中多处时shortcut 会中止并提示用户继续收窄 `--selection-with-ellipsis`,不支持手动指定匹配序号。
- 写入评论前会自动生成符合 OpenAPI 定义的请求体:
- 统一接口:`POST /new_comments`
- 统一字段:`file_type` + `reply_elements`

View File

@@ -195,7 +195,7 @@ lark-cli event +subscribe \
content=$(echo "$line" | jq -r '.content // empty')
[[ -z "$content" ]] && continue
lark-cli docs +update --doc "DOC_URL" --mode append --markdown "- $content"
lark-cli docs +update --api-version v2 --doc "DOC_URL" --command append --doc-format markdown --content "- $content"
done
```

View File

@@ -221,6 +221,19 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
| [`+get-dropdown`](references/lark-sheets-get-dropdown.md) | 查询下拉列表配置 |
| [`+delete-dropdown`](references/lark-sheets-delete-dropdown.md) | 删除下拉列表 |
### 浮动图片
| Shortcut | 说明 |
|----------|------|
| [`+media-upload`](references/lark-sheets-media-upload.md) | 上传本地图片素材,返回 `file_token`(供 `+create-float-image` 使用;>20MB 自动分片) |
| [`+create-float-image`](references/lark-sheets-create-float-image.md) | 创建浮动图片 |
| [`+update-float-image`](references/lark-sheets-update-float-image.md) | 更新浮动图片属性 |
| [`+get-float-image`](references/lark-sheets-get-float-image.md) | 获取浮动图片 |
| [`+list-float-images`](references/lark-sheets-list-float-images.md) | 查询所有浮动图片 |
| [`+delete-float-image`](references/lark-sheets-delete-float-image.md) | 删除浮动图片 |
> 浮动图片相关的读接口只返回元数据(含 `float_image_token`**不包含图片字节**。要读取图片内容,用 token 调 `lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png`。
## API Resources
```bash
@@ -247,6 +260,14 @@ lark-cli sheets <resource> <method> [flags] # 调用 API
- `find` — 查找单元格
### spreadsheet.sheet.float_images
- `create` — 创建浮动图片
- `patch` — 更新浮动图片
- `get` — 获取浮动图片
- `query` — 查询所有浮动图片
- `delete` — 删除浮动图片
## 权限表
| 方法 | 所需 scope |
@@ -259,4 +280,9 @@ lark-cli sheets <resource> <method> [flags] # 调用 API
| `spreadsheet.sheet.filters.get` | `sheets:spreadsheet:read` |
| `spreadsheet.sheet.filters.update` | `sheets:spreadsheet:write_only` |
| `spreadsheet.sheets.find` | `sheets:spreadsheet:read` |
| `spreadsheet.sheet.float_images.create` | `sheets:spreadsheet:write_only` |
| `spreadsheet.sheet.float_images.patch` | `sheets:spreadsheet:write_only` |
| `spreadsheet.sheet.float_images.get` | `sheets:spreadsheet:read` |
| `spreadsheet.sheet.float_images.query` | `sheets:spreadsheet:read` |
| `spreadsheet.sheet.float_images.delete` | `sheets:spreadsheet:write_only` |

View File

@@ -0,0 +1,86 @@
# sheets +create-float-image创建浮动图片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +create-float-image`
在工作表中创建浮动图片。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 前置步骤:获取 float_image_token
`--float-image-token` 由 [`sheets +media-upload`](lark-sheets-media-upload.md) 产出(内部走 `drive/v1/medias/upload_all``>20MB` 自动切到分片上传,详见 [`lark-sheets-media-upload.md`](lark-sheets-media-upload.md)
```bash
# 1. 上传图片,自动计算大小、自动分片
lark-cli sheets +media-upload --url "<url>" --file ./image.png
# 响应: {"file_token":"boxcnXXXX","file_name":"image.png","size":123456,"spreadsheet_token":"<token>"}
# 2. 用返回的 file_token 作为 --float-image-token
lark-cli sheets +create-float-image --url "<url>" --sheet-id "<sheetId>" \
--float-image-token "boxcnXXXX" --range "<sheetId>!A1:A1"
```
> **常见错误**
> - 用 `drive +upload` 的 token → 报 `Wrong Float Image Token`走的是不同的上传接口token 格式不兼容;必须用 `sheets +media-upload`
## 命令
```bash
lark-cli sheets +create-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-token "boxcnXXXX" \
--range "<sheetId>!A1:A1" --width 200 --height 150
# 指定自定义 ID 和偏移
lark-cli sheets +create-float-image --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-token "boxcnXXXX" \
--range "<sheetId>!B2:B2" --width 300 --height 200 \
--offset-x 10 --offset-y 20 --float-image-id "myImg12345"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--float-image-token` | 是 | 图片 token通过上方「前置步骤」的素材上传接口获取不能用 `drive +upload` 的 token |
| `--range` | 是 | 锚定单元格,必须是单格(如 `sheetId!A1:A1`。CLI 会校验前缀必须等于 `--sheet-id` |
| `--width` | 否 | 图片宽度(像素,`>=20`;不传则使用图片原始宽度) |
| `--height` | 否 | 图片高度(像素,`>=20`;不传则使用图片原始高度) |
| `--offset-x` | 否 | 图片**左上角**到**锚定单元格左上角**的横向距离(向右为正,像素);`>=0` 且**小于锚定单元格的宽度**(超限由服务端拒绝) |
| `--offset-y` | 否 | 图片**左上角**到**锚定单元格左上角**的纵向距离(向下为正,像素);`>=0` 且**小于锚定单元格的高度**(超限由服务端拒绝) |
| `--float-image-id` | 否 | 自定义 10 位字母数字 ID不传则自动生成 |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `float_image`float_image_id, float_image_token, range, width, height, offset_x, offset_y。**只返回元数据,不含图片字节**,如需查看图片内容见下方「读取图片内容」。
## 读取图片内容
本接口及 `+get-float-image` / `+list-float-images` 均只返回 `float_image_token`。要读取图片字节,用该 token 调 `docs +media-preview`
```bash
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
```
`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。
## 常见错误
- `1310246 Wrong Float Image Value`width/height/offset 参数不合法CLI 会自动在 hint 中指向 `--width / --height / --offset-x / --offset-y`。典型成因:
- `--width` / `--height` 小于 20
- `--offset-x` 大于等于锚定单元格宽度(或 `--offset-y` 大于等于单元格高度);
- 传了负值。
## 参考
- [lark-sheets-update-float-image](lark-sheets-update-float-image.md)
- [lark-sheets-get-float-image](lark-sheets-get-float-image.md)
- [lark-sheets-list-float-images](lark-sheets-list-float-images.md)
- [lark-sheets-delete-float-image](lark-sheets-delete-float-image.md)

View File

@@ -0,0 +1,37 @@
# sheets +delete-float-image删除浮动图片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +delete-float-image`
删除工作表中的浮动图片。
> [!CAUTION]
> 这是**删除操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
lark-cli sheets +delete-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-id "fi12345678"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--float-image-id` | 是 | 浮动图片 ID |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `code`0=成功)和 `msg`
## 参考
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md)
- [lark-sheets-list-float-images](lark-sheets-list-float-images.md)

View File

@@ -0,0 +1,44 @@
# sheets +get-float-image获取浮动图片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +get-float-image`
获取单个浮动图片的详细信息。
## 命令
```bash
lark-cli sheets +get-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-id "fi12345678"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--float-image-id` | 是 | 浮动图片 ID |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `float_image`float_image_id, float_image_token, range, width, height, offset_x, offset_y。**只返回元数据,不含图片字节**。
## 读取图片内容
本接口只返回 `float_image_token`。要读取图片字节,用 token 调 `docs +media-preview`
```bash
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
```
`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。
## 参考
- [lark-sheets-list-float-images](lark-sheets-list-float-images.md)
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md)

View File

@@ -0,0 +1,43 @@
# sheets +list-float-images查询浮动图片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +list-float-images`
查询工作表中的所有浮动图片。
## 命令
```bash
lark-cli sheets +list-float-images --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `items` 数组,每项为一个 float_image 对象(含 `float_image_token`)。**只返回元数据,不含图片字节**。
## 读取图片内容
本接口只返回 `float_image_token`。要读取图片字节,用 token 调 `docs +media-preview`
```bash
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
```
`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。
## 参考
- [lark-sheets-get-float-image](lark-sheets-get-float-image.md)
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md)

View File

@@ -0,0 +1,74 @@
# sheets +media-upload上传浮动图片素材
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +media-upload`
把本地图片上传到指定电子表格的素材空间,返回 `file_token`,该 token 可以作为 [`+create-float-image`](lark-sheets-create-float-image.md) 的 `--float-image-token` 使用。
> [!CAUTION]
> 这是**写入操作**(创建素材)—— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 说明
- 内部调用 `drive/v1/medias/upload_all``parent_type` 锁定为 `sheet_image``parent_node` 取自 `--url` / `--spreadsheet-token`
- 文件大小通过 `FileIO.Stat` 自动读取,无需手动算(跨平台一致)。
- `>20MB` 自动切换到分片上传(`upload_prepare``upload_part``upload_finish`),无需额外参数。
## 命令
```bash
# 小文件(<=20MB
lark-cli sheets +media-upload --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--file ./image.png
# 也支持 --spreadsheet-token
lark-cli sheets +media-upload --spreadsheet-token "shtxxxxxxxx" --file ./image.png
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--file` | 是 | 本地图片路径,**必须是相对当前工作目录的相对路径**(见下方「注意事项」);>20MB 自动分片 |
| `--dry-run` | 否 | 仅打印请求计划,不执行 |
## 输出
```json
{
"file_token": "boxcnXXXX",
"file_name": "image.png",
"size": 358934,
"spreadsheet_token": "shtxxxxxxxx"
}
```
## 典型用法:上传 + 插入
```bash
# 1. 上传
TOKEN=$(lark-cli sheets +media-upload --url "<url>" --file ./image.png --jq '.data.file_token')
# 2. 插入浮动图片
lark-cli sheets +create-float-image --url "<url>" --sheet-id "<sheetId>" \
--float-image-token "$TOKEN" --range "<sheetId>!A1:A1" --width 300 --height 200
```
## 注意事项
- **`--file` 只接受当前工作目录CWD下的相对路径**。CLI 的 `SafeInputPath` 会拒绝绝对路径以及逃出 CWD 的路径(`..` 展开后超出 CWD 也会拒)。
- ❌ 错误:`--file /Users/alice/Desktop/image.png`
- ❌ 错误:`--file ~/Desktop/image.png`shell 会展开为绝对路径)
- ✅ 正确:`cp /Users/alice/Desktop/image.png ./image.png && lark-cli sheets +media-upload --file ./image.png ...`
- 典型报错:`unsafe file path: --file must be a relative path within the current directory`
- 所需权限:`docs:document.media:upload`(与 docs/slides/base 的媒体上传共用同一 scope
- 返回的 `file_token` **只能**用于浮动图片;走 `drive +upload` 拿到的 token 格式不兼容,会报 `Wrong Float Image Token`
## 参考
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md) — 用返回的 token 创建浮动图片
- [lark-sheets-get-float-image](lark-sheets-get-float-image.md) — 读取浮动图片元数据

View File

@@ -0,0 +1,52 @@
# sheets +update-float-image更新浮动图片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +update-float-image`
更新浮动图片的位置、大小和偏移量。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
lark-cli sheets +update-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-id "fi12345678" \
--width 400 --height 300 --offset-y 20
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--float-image-id` | 是 | 浮动图片 ID |
| `--range` | 否 | 新锚定单元格,必须是单格(如 `sheetId!B2:B2`。CLI 会校验前缀必须等于 `--sheet-id` |
| `--width` | 否 | 图片宽度(像素,`>=20` |
| `--height` | 否 | 图片高度(像素,`>=20` |
| `--offset-x` | 否 | 图片**左上角**到**锚定单元格左上角**的横向距离(向右为正,像素);`>=0` 且**小于锚定单元格的宽度**(超限由服务端拒绝) |
| `--offset-y` | 否 | 图片**左上角**到**锚定单元格左上角**的纵向距离(向下为正,像素);`>=0` 且**小于锚定单元格的高度**(超限由服务端拒绝) |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
> 必须至少传入 `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 其中之一;只传 ID 会被 CLI 拦截,避免 PATCH 空对象导致的无操作或服务端错误。
## 输出
JSON包含更新后的 `float_image` 对象。**只返回元数据,不含图片字节**,如需查看图片内容用 `float_image_token``docs +media-preview`(见 [`lark-sheets-create-float-image.md`](lark-sheets-create-float-image.md) 的「读取图片内容」小节)。
## 常见错误
- `1310246 Wrong Float Image Value`width/height/offset 参数不合法CLI 会自动在 hint 中指向 `--width / --height / --offset-x / --offset-y`。典型成因:
- `--width` / `--height` 小于 20
- `--offset-x` 大于等于锚定单元格宽度(或 `--offset-y` 大于等于单元格高度);
- 传了负值。
## 参考
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md)
- [lark-sheets-get-float-image](lark-sheets-get-float-image.md)

View File

@@ -35,7 +35,7 @@ metadata:
3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 `<whiteboard>`** 标签是封面图AI 生成的总结可视化),应同时下载展示给用户:
```bash
# 1. 读取纪要内容
lark-cli docs +fetch --doc <note_doc_token>
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
# 2. 从返回的 markdown 中提取第一个 <whiteboard token="xxx"/> 的 token
# 3. 下载封面图到 artifact 目录(和逐字稿同目录,保持产物归拢)
# 并非所有纪要都有封面画板,没有 <whiteboard> 标签时跳过即可
@@ -63,7 +63,7 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
3. 需要获取文档内容时,使用 `lark-cli docs +fetch`
```bash
# 获取文档内容
lark-cli docs +fetch --doc <doc_token>
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
```
## 资源关系

View File

@@ -4,6 +4,7 @@ version: 1.0.0
description: >
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL转成 OpenAPI 格式、PlantUML/Mermaid 格式更新画板内容。
当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill无论是否提及"画板"。
⚠️ 原 `lark-whiteboard-cli` skill 已合并至本 skill若 skill 列表中同时存在 `lark-whiteboard-cli`,请忽略它,统一使用本 skill`lark-whiteboard`),并提示用户运行 `npx skills remove lark-whiteboard-cli -g` 删除旧 skill。
metadata:
requires:
bins: ["lark-cli"]
@@ -56,7 +57,7 @@ metadata:
|---|---|
| 直接给了 whiteboard token`wbcnXXX`| 直接使用 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --doc <doc_id> --mode append --markdown '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.board_tokens[0]` 取得(参数详见 lark-doc SKILL.md|
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.board_tokens[0]` 取得(参数详见 lark-doc SKILL.md|
**Step 2渲染 & 写入**

View File

@@ -92,9 +92,9 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
阅读 [`../lark-doc/SKILL.md`](../lark-doc/SKILL.md) 学习云文档技能。
```bash
lark-cli docs +create --title "会议纪要汇总 (<start> - <end>)" --markdown "<内容>"
lark-cli docs +create --api-version v2 --doc-format markdown --content "<title>会议纪要汇总 (<start> - <end>)</title>\n<内容>"
# 或追加到已有文档
lark-cli docs +update --doc "<url_or_token>" --mode append --markdown "<内容>"
lark-cli docs +update --api-version v2 --doc "<url_or_token>" --command append --doc-format markdown --content "<内容>"
```
## 参考