Compare commits

..

13 Commits

Author SHA1 Message Date
liangshuo-1
776ee686ff chore: release v1.0.17 (#614)
Change-Id: I12f59a72996c9d21dacd5478190a85af765bb1a4
2026-04-22 20:17:43 +08:00
chenxingtong-bytedance
4da6d610e2 feat(im): use Content-Disposition filename when downloading message resources (#536)
When downloading message resources, the saved filename was always derived from
file_key (e.g. file_v2_abc123.xlsx), ignoring the original filename the
sender uploaded. This PR resolves filenames from the Content-Disposition
response header first, falling back to Content-Type-based extension inference
only when the header is absent.

Change-Id: I68b48cf428aa8aded4ad9d55fa042f9d68263c3a
2026-04-22 19:52:27 +08:00
zl-bytedance
3f4352d50c feat: add image support to whiteboard-cli skill (#553)
* feat: add image support to whiteboard-cli skill

- Add references/image.md with image processing workflow
- Update content.md with strict image trigger condition
- Update schema.md with Image node type definition
- Update layout.md with image card layout rules
- Add scenes/photo-showcase.md for image showcase layouts
- Strict trigger: only when user explicitly requests images/配图/插图

* docs: sanitize image.md examples - remove real token, use placeholder URLs, cross-platform file check
2026-04-22 18:54:36 +08:00
wittam-01
543a8365d6 docs: clarify that lark-drive comment listing defaults to unresolved comments only (#609)
Change-Id: Ie4200fe14f1e3c4735c1fcc4aba4a3f9a4900e22
2026-04-22 17:37:23 +08:00
fangshuyu-768
0192cee859 docs(lark-doc): fix --markdown examples that teach literal \n (#602)
Skill examples taught the pattern --markdown "## A\n\n- x\n- y",
which in bash double quotes is a literal backslash + n, not a
newline. lark-cli forwards the value byte-for-byte to MCP, so
the resulting Feishu doc renders "\n\n" as visible text. Agents
and users copy-pasting the examples reliably produced broken
docs.

Documentation-only fix (issue #580 Option 1, non-breaking):

- Replace 9 "...\n..." examples with multi-line quoted strings,
  plus 1 single-quoted example that had the same bug inside
  Markdown-block content
- Add a one-sentence warning callout at the top of each file
- Add a stdin/heredoc example in lark-doc-create.md for longer
  content
- Leave existing $'...' ANSI-C examples untouched — those
  already produce real newlines

No CLI behavior change. Byte-for-byte forwarding is standard
shell semantics; auto-interpreting \n (Option 2) would be a
breaking change and is intentionally not pursued.

Fixes #580
2026-04-22 16:50:24 +08:00
sang-neo03
18e227f281 feat(cmdutil): add X-Cli-Build header for CLI build classification (#596)
* feat(cmdutil): add X-Cli-Build header for CLI build classification

  Adds X-Cli-Build (official / extended / unknown) so the gateway can distinguish official CLI from ISV-repackaged builds.

* test(cmdutil): lift coverage on build-kind classification

Extract classifyBuild as a pure helper so every branch (unknown / extended
main-path / extended credential / extended transport / extended fileio /
official) is reachable without mutating process-wide provider registries.

Also cover: isBuiltinProvider non-pointer values, BuildHeaderTransport
nil-Base fallback path, and fix the Name-spoof test so the test double
returns a value that actually mimics an ISV provider.

Coverage on PR-changed functions:
- classifyBuild: 100% (new)
- computeBuildKind: 61.5% -> 93.3%
- BuildHeaderTransport.RoundTrip: 80% -> 100%
2026-04-22 16:30:32 +08:00
caojie0621
7e9beec422 feat(drive): add +apply-permission to request doc access (#588)
Wrap the POST /drive/v1/permissions/:token/members/apply endpoint as a
user-only shortcut. --token accepts either a bare token or a document
URL, with type auto-inferred from the URL path (/docx/, /sheets/,
/base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /minutes/,
/slides/); an explicit --type always wins. --perm is limited to view or
edit; full_access is rejected client-side to match the spec.

Classifier gains two domain-specific hints for the endpoint's newly
documented error codes: 1063006 (per-user-per-document quota of 5/day
reached) and 1063007 (document does not accept apply requests — covers
disallow-external-apply, already-has-access, and unsupported-type).

test(drive): add dry-run E2E for +apply-permission

Invoke the real CLI binary via clie2e.RunCmd under --dry-run and
parse the rendered request JSON with gjson to lock in method, URL
path (including the token segment), type query parameter (auto-inferred
for docx / sheet / slides URLs, taken from explicit --type for bare
tokens), perm body field, and remark presence/omission. A separate
test asserts --perm full_access is rejected by the enum validator
before reaching the server. Fake LARKSUITE_CLI_APP_ID / APP_SECRET /
BRAND are enough because dry-run short-circuits before any API call.

Update drive coverage.md to add a row and refresh metrics.

test(drive): isolate E2E dry-run subprocess from local CLI config

Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in both +apply-permission
dry-run tests so the subprocess can't read a developer's real
credentials/profile instead of the fake env vars the tests inject.

test(drive): add E2E case that exercises URL inference override

Previous "bare token with explicit type wins over inference" row used a
bare token, which has no URL-derived type to override. Replace it with
a /docx/ URL + --type wiki combo that actually forces the explicit flag
to win over URL inference, and add a separate bare-token row to keep
the simpler path covered. Refresh coverage.md wording to match.
2026-04-22 16:28:48 +08:00
chanthuang
462d38e8f7 docs(mail): remove get_signatures from skill reference, exposed via +signature instead (#545)
Change-Id: I3463cbd08d595c1cb9cda4fadc6e2a5ad1c62189
2026-04-22 16:08:56 +08:00
kongenpei
e4d263948c fix(base): add default-table follow-up hint to base-create (#600)
* fix(base): add default-table follow-up hint to base-create

* fix(base): route base-create hint to stderr

* fix(base): prefix base-create stderr tip

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-22 14:53:58 +08:00
tuxedomm
11191df703 fix: skip flag-completion registration outside completion path (#598)
* 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 11:55:11 +08:00
yballul-bytedance
e23b3a8dc6 fix: add record-share-link-create in SKILL.md (#597)
Change-Id: Ie8dc96521ee692804b734b030f7c143171193eb9
2026-04-22 11:54:01 +08:00
yballul-bytedance
f3699298aa feat: cli 支持记录分享 no-meego (#466)
Change-Id: Ie78da99096cc1fc8a4671d8178176f4c587466ba
2026-04-22 10:31:37 +08:00
chanthuang
018eeb6414 fix(mail): remove leftover conflict marker in skill docs (#594)
The <<<<<<< HEAD marker was accidentally left in mail.md and SKILL.md
by commit cb301a3 (draft preview URL). Remove it.

Change-Id: I6e1d5c0c66761302a3c4ee1421a16961b666bd80
2026-04-21 21:58:01 +08:00
53 changed files with 2231 additions and 58 deletions

View File

@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file.
## [v1.0.17] - 2026-04-22
### Features
- **im**: Use `Content-Disposition` filename when downloading message resources (#536)
- **drive**: Add `+apply-permission` to request doc access (#588)
- Support record share link (#466)
- **whiteboard**: Add image support to `whiteboard-cli` skill (#553)
- **cmdutil**: Add `X-Cli-Build` header for CLI build classification (#596)
### Bug Fixes
- **base**: Add default-table follow-up hint to `base-create` (#600)
- Skip flag-completion registration outside completion path (#598)
- Add `record-share-link-create` in `SKILL.md` (#597)
- **mail**: Remove leftover conflict marker in skill docs (#594)
### Documentation
- **drive**: Clarify that comment listing defaults to unresolved comments only (#609)
- **doc**: Fix `--markdown` examples that teach literal `\n` (#602)
- **mail**: Remove `get_signatures` from skill reference, exposed via `+signature` instead (#545)
## [v1.0.16] - 2026-04-21
### Features
@@ -441,6 +464,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14

View File

@@ -100,7 +100,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
}
return nil, 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
})

View File

@@ -85,6 +85,8 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
configureFlagCompletions(os.Args)
f, rootCmd := buildInternal(
context.Background(), inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
@@ -153,6 +155,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

@@ -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

@@ -377,7 +377,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
cmd.ValidArgsFunction = completeSchemaPath(f)
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
})

View File

@@ -189,7 +189,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
}
_ = 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

@@ -132,6 +132,7 @@ func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = util.SharedTransport()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
}

View File

@@ -54,7 +54,7 @@ func addIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target
}
registerIdentityFlag(cmd, target, cfg.defaultValue, cfg.usage)
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return cfg.completionValues, cobra.ShellCompDirectiveNoFileComp
})
}

View File

@@ -6,7 +6,14 @@ package cmdutil
import (
"context"
"net/http"
"reflect"
"runtime/debug"
"strings"
"sync"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/build"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -14,12 +21,21 @@ import (
const (
HeaderSource = "X-Cli-Source"
HeaderVersion = "X-Cli-Version"
HeaderBuild = "X-Cli-Build"
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
SourceValue = "lark-cli"
HeaderUserAgent = "User-Agent"
// BuildKindOfficial / BuildKindExtended / BuildKindUnknown are the values
// reported in the X-Cli-Build header; see DetectBuildKind for semantics.
BuildKindOfficial = "official"
BuildKindExtended = "extended"
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -32,10 +48,108 @@ func BaseSecurityHeaders() http.Header {
h := make(http.Header)
h.Set(HeaderSource, SourceValue)
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
return h
}
var (
buildKindOnce sync.Once
buildKindVal string
)
// DetectBuildKind reports whether this binary is the official CLI, an
// extended/repackaged build, or unknown. The result is cached via sync.Once
// so it is computed only on the first call.
//
// IMPORTANT: must NOT be called from any package init(). Go's init ordering
// follows the import graph; ISV providers registered via blank import may not
// have run yet, which would misclassify an extended build as official. Call
// only when handling an actual request (e.g. from BaseSecurityHeaders).
func DetectBuildKind() string {
buildKindOnce.Do(func() {
buildKindVal = computeBuildKind()
})
return buildKindVal
}
// computeBuildKind performs the actual detection without any caching.
// Exposed for tests. Gathers runtime/global inputs and delegates the pure
// branching logic to classifyBuild so that logic can be unit-tested without
// mutating process-wide provider registries.
func computeBuildKind() string {
info, ok := debug.ReadBuildInfo()
mainPath := ""
if ok {
mainPath = info.Main.Path
}
credProviders := credential.Providers()
creds := make([]any, len(credProviders))
for i, p := range credProviders {
creds[i] = p
}
var tp any
if p := exttransport.GetProvider(); p != nil {
tp = p
}
var fp any
if p := fileio.GetProvider(); p != nil {
fp = p
}
return classifyBuild(mainPath, ok, creds, tp, fp)
}
// classifyBuild is the pure classification logic used by computeBuildKind.
// Callers supply concrete values so every branch is reachable from tests
// without touching debug.ReadBuildInfo or the extension registries.
//
// Priority order mirrors the design doc:
// 1. no build info → unknown
// 2. main module path not the official one → extended (ISV wrapper)
// 3. any non-builtin provider (credential / transport / fileio) → extended
// 4. otherwise → official
func classifyBuild(mainPath string, haveBuildInfo bool, credProviders []any, transportProvider, fileioProvider any) string {
if !haveBuildInfo {
return BuildKindUnknown
}
if mainPath != "" && mainPath != officialModulePath {
return BuildKindExtended
}
for _, p := range credProviders {
if !isBuiltinProvider(p) {
return BuildKindExtended
}
}
if transportProvider != nil && !isBuiltinProvider(transportProvider) {
return BuildKindExtended
}
if fileioProvider != nil && !isBuiltinProvider(fileioProvider) {
return BuildKindExtended
}
return BuildKindOfficial
}
// isBuiltinProvider reports whether p is declared under the official module
// path. Third-party providers live under their own module and fail this check.
// Using reflect.PkgPath makes this robust against Name() spoofing since
// package paths are fixed at compile time.
func isBuiltinProvider(p any) bool {
if p == nil {
return false
}
t := reflect.TypeOf(p)
if t == nil {
return false
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
pkg := t.PkgPath()
return pkg == officialModulePath || strings.HasPrefix(pkg, officialModulePath+"/")
}
// ── Context utilities ──
type ctxKey string

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package cmdutil
import (
"testing"
sidecarcred "github.com/larksuite/cli/extension/credential/sidecar"
sidecartrans "github.com/larksuite/cli/extension/transport/sidecar"
)
// TestIsBuiltinProvider_SidecarProviders locks the classification for the
// sidecar-mode providers enumerated in design doc §3.3.2 as "官方自带". These
// types only compile when the `authsidecar` build tag is active, so the test
// is guarded by the same tag.
func TestIsBuiltinProvider_SidecarProviders(t *testing.T) {
cases := []struct {
name string
provider any
}{
{"sidecar credential provider", &sidecarcred.Provider{}},
{"sidecar transport provider", &sidecartrans.Provider{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !isBuiltinProvider(tc.provider) {
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
}
})
}
}

View File

@@ -0,0 +1,262 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"net/http"
"testing"
"github.com/larksuite/cli/extension/credential"
envcred "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// ---------------------------------------------------------------------------
// isBuiltinProvider
// ---------------------------------------------------------------------------
// cmdutilLocalProvider has PkgPath under the official module
// ("github.com/larksuite/cli/internal/cmdutil") and should be classified
// as builtin.
type cmdutilLocalProvider struct{}
// Name intentionally returns a value that mimics an external provider; the
// PkgPath-based classifier must ignore it. See TestIsBuiltinProvider_PkgPathNotSpoofableByName.
func (cmdutilLocalProvider) Name() string { return "external-spoofed-provider" }
func (cmdutilLocalProvider) ResolveAccount(context.Context) (*credential.Account, error) {
return nil, nil
}
func (cmdutilLocalProvider) ResolveToken(context.Context, credential.TokenSpec) (*credential.Token, error) {
return nil, nil
}
func TestIsBuiltinProvider_Nil(t *testing.T) {
if isBuiltinProvider(nil) {
t.Fatal("isBuiltinProvider(nil) = true, want false")
}
}
func TestIsBuiltinProvider_TypeUnderOfficialModule(t *testing.T) {
if !isBuiltinProvider(&cmdutilLocalProvider{}) {
t.Fatal("type under github.com/larksuite/cli/... should be builtin")
}
}
func TestIsBuiltinProvider_StdlibTypeIsNotBuiltin(t *testing.T) {
// A standard library type has PkgPath "net/http" — outside official module.
// This covers the non-builtin branch, which we cannot trigger from inside
// this test file using a locally-defined type.
if isBuiltinProvider(&http.Server{}) {
t.Fatal("stdlib type classified as builtin, PkgPath check is broken")
}
}
func TestIsBuiltinProvider_PkgPathNotSpoofableByName(t *testing.T) {
// Name() returns a string, but classification uses reflect.Type.PkgPath
// which is compile-time fixed. The local type returns a name that looks
// like an ISV provider; it must still classify as builtin.
p := &cmdutilLocalProvider{}
if p.Name() != "external-spoofed-provider" {
t.Fatalf("sanity check: Name() = %q, spoof value lost", p.Name())
}
if !isBuiltinProvider(p) {
t.Fatal("isBuiltinProvider should decide by PkgPath, not Name()")
}
}
// TestIsBuiltinProvider_NonPointerValues covers the non-pointer reflect branch.
// The existing tests only exercise pointer receivers (&T{}); when a provider
// is passed by value the reflect.Kind is not Ptr and t.Elem() is skipped.
func TestIsBuiltinProvider_NonPointerValues(t *testing.T) {
if !isBuiltinProvider(cmdutilLocalProvider{}) {
t.Fatal("non-pointer local type should be builtin (PkgPath still under official module)")
}
// http.Server as a non-pointer — PkgPath "net/http", not under official.
if isBuiltinProvider(http.Server{}) {
t.Fatal("non-pointer stdlib type should not be builtin")
}
}
// TestIsBuiltinProvider_RealBuiltinProviders locks down the classification
// for the concrete providers enumerated in design doc §3.3.2 as "官方自带":
// env credential provider and local fileio provider. If any of these is
// moved out of the official module tree in the future, this test must flip
// red so the new package path is explicitly considered.
//
// The sidecar providers (extension/credential/sidecar and
// extension/transport/sidecar) are guarded by the `authsidecar` build tag
// and covered in secheader_sidecar_test.go under that tag.
func TestIsBuiltinProvider_RealBuiltinProviders(t *testing.T) {
cases := []struct {
name string
provider any
}{
{"env credential provider", &envcred.Provider{}},
{"local fileio provider", &localfileio.Provider{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !isBuiltinProvider(tc.provider) {
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
}
})
}
}
// ---------------------------------------------------------------------------
// computeBuildKind
// ---------------------------------------------------------------------------
func TestComputeBuildKind_ReturnsKnownValue(t *testing.T) {
// Under `go test`, Main.Path is typically the module being tested
// ("github.com/larksuite/cli"); the concrete return may still be
// official, extended, or unknown depending on Main.Path and the
// registered providers. Just assert it's one of the defined values.
got := computeBuildKind()
switch got {
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
default:
t.Fatalf("computeBuildKind() = %q, want one of official/extended/unknown", got)
}
}
// ---------------------------------------------------------------------------
// classifyBuild — pure branching logic
// ---------------------------------------------------------------------------
//
// These tests cover every branch of classifyBuild with explicit inputs,
// which is impossible from computeBuildKind alone because debug.ReadBuildInfo
// and the process-wide provider registries can't be reshaped in a test.
func TestClassifyBuild_NoBuildInfo_ReturnsUnknown(t *testing.T) {
if got := classifyBuild("", false, nil, nil, nil); got != BuildKindUnknown {
t.Fatalf("classifyBuild(haveBuildInfo=false) = %q, want %q", got, BuildKindUnknown)
}
}
func TestClassifyBuild_ExtendedMainPath_ReturnsExtended(t *testing.T) {
cases := []string{
"github.com/acme/lark-cli-wrapper",
"example.com/isv/lark",
"gitlab.mycorp.internal/tools/lark-cli-fork",
}
for _, mp := range cases {
t.Run(mp, func(t *testing.T) {
if got := classifyBuild(mp, true, nil, nil, nil); got != BuildKindExtended {
t.Fatalf("mainPath=%q classifyBuild = %q, want %q", mp, got, BuildKindExtended)
}
})
}
}
func TestClassifyBuild_OfficialMainPath_NoProviders_ReturnsOfficial(t *testing.T) {
if got := classifyBuild(officialModulePath, true, nil, nil, nil); got != BuildKindOfficial {
t.Fatalf("classifyBuild(official, no providers) = %q, want %q", got, BuildKindOfficial)
}
}
func TestClassifyBuild_EmptyMainPath_DoesNotTriggerExtended(t *testing.T) {
// An empty Main.Path (rare, e.g. `go run` pre-1.18) must not be treated
// as extended by itself — the classifier falls through to provider checks.
if got := classifyBuild("", true, nil, nil, nil); got != BuildKindOfficial {
t.Fatalf("classifyBuild(empty mainPath, no providers) = %q, want %q", got, BuildKindOfficial)
}
}
func TestClassifyBuild_NonBuiltinCredentialProvider_ReturnsExtended(t *testing.T) {
// Any non-builtin credential provider flips the verdict to extended.
got := classifyBuild(officialModulePath, true, []any{&http.Server{}}, nil, nil)
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external credential = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_MixedCredentialProviders_ExtendedWins(t *testing.T) {
// Even if most providers are builtin, a single external one decides.
providers := []any{&cmdutilLocalProvider{}, &http.Server{}}
if got := classifyBuild(officialModulePath, true, providers, nil, nil); got != BuildKindExtended {
t.Fatalf("classifyBuild mixed providers = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_NonBuiltinTransportProvider_ReturnsExtended(t *testing.T) {
got := classifyBuild(officialModulePath, true, nil, &http.Server{}, nil)
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external transport = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_NonBuiltinFileioProvider_ReturnsExtended(t *testing.T) {
got := classifyBuild(officialModulePath, true, nil, nil, &http.Server{})
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external fileio = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_AllBuiltinProviders_ReturnsOfficial(t *testing.T) {
// All three slots filled with builtin providers must still classify as official.
got := classifyBuild(
officialModulePath, true,
[]any{&cmdutilLocalProvider{}},
&cmdutilLocalProvider{},
&cmdutilLocalProvider{},
)
if got != BuildKindOfficial {
t.Fatalf("classifyBuild all-builtin = %q, want %q", got, BuildKindOfficial)
}
}
// TestClassifyBuild_MainPathPriorityOverProviders documents that the main
// module path takes precedence: even with only builtin providers, a non-
// official main path still yields extended.
func TestClassifyBuild_MainPathPriorityOverProviders(t *testing.T) {
got := classifyBuild(
"github.com/acme/lark-wrapper", true,
[]any{&cmdutilLocalProvider{}},
&cmdutilLocalProvider{},
&cmdutilLocalProvider{},
)
if got != BuildKindExtended {
t.Fatalf("main-path override failed: got %q, want %q", got, BuildKindExtended)
}
}
// ---------------------------------------------------------------------------
// DetectBuildKind — sync.Once caching
// ---------------------------------------------------------------------------
func TestDetectBuildKind_StableAcrossCalls(t *testing.T) {
a := DetectBuildKind()
b := DetectBuildKind()
if a != b {
t.Fatalf("DetectBuildKind() returned different values on repeat: %q vs %q", a, b)
}
}
// ---------------------------------------------------------------------------
// BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
h := BaseSecurityHeaders()
v := h.Get(HeaderBuild)
if v == "" {
t.Fatal("BaseSecurityHeaders missing X-Cli-Build header")
}
switch v {
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
default:
t.Fatalf("X-Cli-Build = %q, want one of official/extended/unknown", v)
}
}
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
h := BaseSecurityHeaders()
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} {
if h.Get(key) == "" {
t.Errorf("BaseSecurityHeaders missing %s", key)
}
}
}

View File

@@ -72,6 +72,24 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
return util.FallbackTransport().RoundTrip(req)
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
// X-Cli-Build header before every request. Used in the SDK transport chain,
// where SecurityHeaderTransport is not installed, to prevent extensions from
// tampering with the build classification. The direct HTTP chain is already
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
type BuildHeaderTransport struct {
Base http.RoundTripper
}
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderBuild, DetectBuildKind())
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return util.FallbackTransport().RoundTrip(req)
}
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
// headers into every request. Shortcut headers are read from the request context.
type SecurityHeaderTransport struct {

View File

@@ -97,13 +97,18 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
@@ -116,7 +121,7 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -125,9 +130,13 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
ua, ok := sec.Base.(*UserAgentTransport)
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
@@ -139,13 +148,18 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
@@ -236,6 +250,115 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
}
}
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
// value before the request leaves the process.
type buildTamperingInterceptor struct{}
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Del(HeaderBuild)
req.Header.Set(HeaderBuild, "ext-tampered-build")
return nil
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
// closes the gap where the SDK chain had no equivalent of
// SecurityHeaderTransport (see design doc §3.3.3).
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}
base = &BuildHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if receivedBuild == "ext-tampered-build" {
t.Fatalf("%s = %q, extension tampering leaked to network", HeaderBuild, receivedBuild)
}
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
}
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
// no extension is registered, BuildHeaderTransport writes X-Cli-Build.
func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
transport := &BuildHeaderTransport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if receivedBuild == "" {
t.Fatalf("%s header missing, BuildHeaderTransport did not inject", HeaderBuild)
}
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
}
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
// the transport still sets X-Cli-Build and routes the request through
// util.FallbackTransport rather than panicking. This covers the fallback
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
transport := &BuildHeaderTransport{Base: nil}
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request via nil-Base transport failed: %v", err)
}
resp.Body.Close()
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q (header must be set even on nil-Base path)",
HeaderBuild, receivedBuild, want)
}
}
// interceptorFunc adapts a function to exttransport.Interceptor.
type interceptorFunc func(*http.Request) func(*http.Response, error)

View File

@@ -41,6 +41,14 @@ const (
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
// Drive permission apply: per-user-per-document submission limit (5/day) reached.
LarkErrDrivePermApplyRateLimit = 1063006
// Drive permission apply: request is not applicable for this document
// (e.g. the document is configured to disallow access requests, or the
// caller already holds the requested permission, or the target type does
// not accept apply operations).
LarkErrDrivePermApplyNotApplicable = 1063007
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -82,6 +90,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
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"
// drive permission-apply specific guidance
case LarkErrDrivePermApplyRateLimit:
return ExitAPI, "rate_limit",
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
case LarkErrDrivePermApplyNotApplicable:
return ExitAPI, "invalid_params",
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
}
return ExitAPI, "api_error", ""

View File

@@ -47,6 +47,20 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
wantType: "invalid_params",
wantHint: "--width / --height / --offset-x / --offset-y",
},
{
name: "drive permission apply rate limit",
code: LarkErrDrivePermApplyRateLimit,
wantExitCode: ExitAPI,
wantType: "rate_limit",
wantHint: "5 times per day",
},
{
name: "drive permission apply not applicable",
code: LarkErrDrivePermApplyNotApplicable,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantHint: "does not accept a permission-apply request",
},
}
for _, tt := range tests {

View File

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

View File

@@ -67,11 +67,15 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
parent.SilenceErrors = true
parent.SilenceUsage = true
stdout.Reset()
if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok {
stderr.Reset()
}
return parent.ExecuteContext(context.Background())
}
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
@@ -96,6 +100,9 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
if data["created"] != true {
t.Fatalf("created = %#v, want true", data["created"])
}
if !strings.Contains(stderr.String(), baseCreateHint) {
t.Fatalf("stderr = %q, want %q", stderr.String(), baseCreateHint)
}
base, _ := data["base"].(map[string]interface{})
if got := common.GetString(base, "app_token"); got != "app_x" {
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
@@ -184,6 +191,7 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases",
@@ -198,6 +206,9 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *test
}
data := decodeBaseEnvelope(t, stdout)
if !strings.Contains(stderr.String(), baseCreateHint) {
t.Fatalf("stderr = %q, want %q", stderr.String(), baseCreateHint)
}
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)

View File

@@ -5,11 +5,14 @@ package base
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const baseCreateHint = "Tip: New bases include a default empty table with 5-10 blank records. After finishing table/field setup on this base, ask whether to delete that default table. If yes, run +table-list first, then delete the default table."
func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token").
@@ -65,6 +68,7 @@ func executeBaseCreate(runtime *common.RuntimeContext) error {
out := map[string]interface{}{"base": data, "created": true}
augmentBasePermissionGrant(runtime, out, data)
runtime.Out(out, nil)
fmt.Fprintln(runtime.IO().ErrOut, baseCreateHint)
return nil
}

View File

@@ -132,7 +132,7 @@ func TestShortcutsCatalog(t *testing.T) {
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-upload-attachment", "+record-delete",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-delete",
"+record-history-list",
"+base-get", "+base-copy", "+base-create",
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",

View File

@@ -112,6 +112,56 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
Set("base_token", runtime.Str("base-token"))
}
func dryRunRecordShareBatch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
recordIDs := deduplicateRecordIDs(runtime)
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/share_links/batch").
Body(map[string]interface{}{"record_ids": recordIDs}).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
}
const maxShareBatchSize = 100
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
recordIDs := deduplicateRecordIDs(runtime)
if len(recordIDs) == 0 {
return common.FlagErrorf("--record-ids is required and must not be empty")
}
if len(recordIDs) > maxShareBatchSize {
return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
}
return nil
}
func deduplicateRecordIDs(runtime *common.RuntimeContext) []string {
raw := runtime.StrSlice("record-ids")
seen := make(map[string]bool, len(raw))
result := make([]string, 0, len(raw))
for _, id := range raw {
if id != "" && !seen[id] {
seen[id] = true
result = append(result, id)
}
}
return result
}
func executeRecordShareBatch(runtime *common.RuntimeContext) error {
recordIDs := deduplicateRecordIDs(runtime)
body := map[string]interface{}{
"record_ids": recordIDs,
}
data, err := baseV3Call(runtime, "POST",
baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "share_links", "batch"),
nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
func validateRecordJSON(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseRecordShareLinkCreate = common.Shortcut{
Service: "base",
Command: "+record-share-link-create",
Description: "Generate share links for one or more records (max 100 per request)",
Risk: "read",
Scopes: []string{"base:record:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
},
Tips: []string{
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`,
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordShareBatch(runtime)
},
DryRun: dryRunRecordShareBatch,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordShareBatch(runtime)
},
}

View File

@@ -42,6 +42,7 @@ func Shortcuts() []common.Shortcut {
BaseRecordUpsert,
BaseRecordBatchCreate,
BaseRecordBatchUpdate,
BaseRecordShareLinkCreate,
BaseRecordUploadAttachment,
BaseRecordDelete,
BaseRecordHistoryList,

View File

@@ -181,6 +181,12 @@ func (ctx *RuntimeContext) StrArray(name string) []string {
return v
}
// StrSlice returns a string-slice flag value (supports CSV splitting and repeated flags).
func (ctx *RuntimeContext) StrSlice(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringSlice(name)
return v
}
// ── API helpers ──
// CallAPI uses an internal HTTP wrapper with limited control over request/response.
@@ -857,6 +863,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmd.Flags().Int(fl.Name, d, desc)
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice":
cmd.Flags().StringSlice(fl.Name, nil, desc)
default:
cmd.Flags().String(fl.Name, fl.Default, desc)
}
@@ -868,7 +876,7 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
}
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
})
}
@@ -884,7 +892,7 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
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

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// permApplyTypes is the authoritative list of type values the apply-permission
// endpoint accepts for its required `type` query parameter.
var permApplyTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "slides",
}
// permApplyURLMarkers maps document URL path markers to the `type` value the
// apply-permission endpoint expects. Markers are disjoint strings (each begins
// with "/" and ends with "/"), so a simple substring scan disambiguates them.
var permApplyURLMarkers = []struct {
Marker string
Type string
}{
{"/wiki/", "wiki"},
{"/docx/", "docx"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/file/", "file"},
{"/mindnote/", "mindnote"},
{"/slides/", "slides"},
{"/doc/", "doc"},
}
// resolvePermApplyTarget extracts (token, type) from a user-supplied --token
// value that may be either a bare token or a full document URL, plus an
// optional explicit --type. Explicit --type wins over URL inference.
func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", output.ErrValidation("--token is required")
}
if strings.Contains(raw, "://") {
for _, m := range permApplyURLMarkers {
if tok, ok := extractURLToken(raw, m.Marker); ok {
token = tok
if explicitType == "" {
docType = m.Type
}
break
}
}
if token == "" {
return "", "", output.ErrValidation(
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
)
}
} else {
token = raw
}
if explicitType != "" {
docType = explicitType
}
if docType == "" {
return "", "", output.ErrValidation(
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(permApplyTypes, ", "),
)
}
return token, docType, nil
}
// DriveApplyPermission applies to the document owner for view or edit access
// on behalf of the invoking user. Matches the open-apis endpoint
// /open-apis/drive/v1/permissions/:token/members/apply.
//
// The backend accepts only user_access_token for this endpoint, so the
// shortcut declares AuthTypes: ["user"] — bot identity is rejected up-front.
var DriveApplyPermission = common.Shortcut{
Service: "drive",
Command: "+apply-permission",
Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/slides",
Risk: "write",
Scopes: []string{"docs:permission.member:apply"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: permApplyTypes},
{Name: "perm", Desc: "permission to request", Required: true, Enum: []string{"view", "edit"}},
{Name: "remark", Desc: "optional note shown on the request card sent to the owner"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, docType, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildPermApplyBody(runtime)
return common.NewDryRunAPI().
Desc("Apply to document owner for access").
POST("/open-apis/drive/v1/permissions/:token/members/apply").
Params(map[string]interface{}{"type": docType}).
Body(body).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, docType, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return err
}
body := buildPermApplyBody(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Requesting %s access on %s %s...\n",
runtime.Str("perm"), docType, common.MaskToken(token))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/apply", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// buildPermApplyBody returns the request body with the caller-supplied perm
// and optional remark. remark is omitted entirely when empty so the server
// doesn't render an empty note on the request card.
func buildPermApplyBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{"perm": runtime.Str("perm")}
if s := runtime.Str("remark"); s != "" {
body["remark"] = s
}
return body
}

View File

@@ -0,0 +1,238 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── resolvePermApplyTarget unit tests ────────────────────────────────────────
func TestResolvePermApplyTarget_BareTokenNeedsType(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget("bareToken", "")
if err == nil || !strings.Contains(err.Error(), "--type is required") {
t.Fatalf("expected --type required error, got: %v", err)
}
}
func TestResolvePermApplyTarget_BareTokenWithType(t *testing.T) {
t.Parallel()
token, docType, err := resolvePermApplyTarget("bareToken", "docx")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "bareToken" || docType != "docx" {
t.Fatalf("got token=%q type=%q, want bareToken/docx", token, docType)
}
}
func TestResolvePermApplyTarget_URLInference(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
wantTok string
wantType string
}{
{"docx", "https://example.feishu.cn/docx/doxTok123?from=share", "doxTok123", "docx"},
{"sheets", "https://example.feishu.cn/sheets/shtTok456?sheet=abc", "shtTok456", "sheet"},
{"base", "https://example.feishu.cn/base/bscTok789", "bscTok789", "bitable"},
{"bitable", "https://example.feishu.cn/bitable/bscTok789", "bscTok789", "bitable"},
{"file", "https://example.feishu.cn/file/boxTok111", "boxTok111", "file"},
{"wiki", "https://example.feishu.cn/wiki/wikTok222", "wikTok222", "wiki"},
{"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"},
{"mindnote", "https://example.feishu.cn/mindnote/mnTok444", "mnTok444", "mindnote"},
{"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
token, docType, err := resolvePermApplyTarget(tt.raw, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != tt.wantTok || docType != tt.wantType {
t.Fatalf("got (%q,%q), want (%q,%q)", token, docType, tt.wantTok, tt.wantType)
}
})
}
}
func TestResolvePermApplyTarget_ExplicitTypeOverridesURL(t *testing.T) {
t.Parallel()
// Even though the URL marker is /docx/, an explicit --type wins.
token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "doxTok123" || docType != "wiki" {
t.Fatalf("got (%q,%q), want (doxTok123,wiki)", token, docType)
}
}
func TestResolvePermApplyTarget_UnrecognizedURL(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget("https://example.feishu.cn/unknown/xyz", "")
if err == nil || !strings.Contains(err.Error(), "could not infer token") {
t.Fatalf("expected infer error, got: %v", err)
}
}
func TestResolvePermApplyTarget_Empty(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget(" ", "docx")
if err == nil || !strings.Contains(err.Error(), "--token is required") {
t.Fatalf("expected token required error, got: %v", err)
}
}
// ── shortcut integration tests ──────────────────────────────────────────────
func TestDriveApplyPermission_ValidateMissingToken(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveApplyPermission, []string{
"+apply-permission", "--perm", "view", "--type", "docx", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestDriveApplyPermission_ValidateRejectsBadPerm(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveApplyPermission, []string{
"+apply-permission",
"--token", "doxTok",
"--type", "docx",
"--perm", "full_access",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--perm") {
t.Fatalf("expected perm enum error, got: %v", err)
}
}
func TestDriveApplyPermission_DryRunInfersTypeFromURL(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveApplyPermission, []string{
"+apply-permission",
"--token", "https://example.feishu.cn/sheets/shtTok?sheet=abc",
"--perm", "edit",
"--remark", "please",
"--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/permissions/shtTok/members/apply",
`"POST"`,
`"sheet"`,
`"edit"`,
`"please"`,
`"shtTok"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDriveApplyPermission_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
// Stub URL includes "?type=docx" — the stub only matches when the request
// URL contains that query, so this doubles as an assertion that the
// shortcut emits the type query parameter.
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxTok123/members/apply?type=docx",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"applied": true},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveApplyPermission, []string{
"+apply-permission",
"--token", "doxTok123",
"--type", "docx",
"--perm", "view",
"--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)
}
if body["perm"] != "view" {
t.Fatalf("perm = %v, want view", body["perm"])
}
if _, hasRemark := body["remark"]; hasRemark {
t.Fatalf("remark should be omitted when empty, got: %v", body["remark"])
}
}
func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxTok/members/apply",
Status: 400,
Body: map[string]interface{}{
"code": 1063007, "msg": "request not applicable",
},
})
err := mountAndRunDrive(t, DriveApplyPermission, []string{
"+apply-permission",
"--token", "doxTok",
"--type", "docx",
"--perm", "view",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for 1063007")
}
if !strings.Contains(err.Error(), "not applicable") {
t.Fatalf("expected surfaced server message, got: %v", err)
}
}
func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxTok/members/apply",
Status: 429,
Body: map[string]interface{}{
"code": 1063006, "msg": "quota exceeded",
},
})
err := mountAndRunDrive(t, DriveApplyPermission, []string{
"+apply-permission",
"--token", "doxTok",
"--type", "docx",
"--perm", "view",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for 1063006")
}
}

View File

@@ -19,5 +19,6 @@ func Shortcuts() []common.Shortcut {
DriveMove,
DriveDelete,
DriveTaskResult,
DriveApplyPermission,
}
}

View File

@@ -22,6 +22,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+move",
"+delete",
"+task_result",
"+apply-permission",
}
if len(got) != len(want) {

View File

@@ -262,7 +262,7 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
target := filepath.Join("nested", "resource.bin")
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target)
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target, true)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -308,7 +308,7 @@ func TestDownloadIMResourceToPathImageUsesSingleRequestWithoutRange(t *testing.T
cmdutil.TestChdir(t, t.TempDir())
gotPath, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_img", "img_123", "image", "image")
gotPath, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_img", "img_123", "image", "image", true)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -342,7 +342,7 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", "out.bin")
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", "out.bin", true)
if err == nil || !strings.Contains(err.Error(), "HTTP 403: denied") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -372,7 +372,7 @@ func TestDownloadIMResourceToPathRetriesNetworkError(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry", "file_retry", "file", target)
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry", "file_retry", "file", target, true)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -408,7 +408,7 @@ func TestDownloadIMResourceToPathRetrySecondAttemptSuccess(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry2", "file_retry2", "file", target)
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry2", "file_retry2", "file", target, true)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -444,7 +444,7 @@ func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target)
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target, true)
if err != context.Canceled {
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
}
@@ -525,7 +525,7 @@ func TestDownloadIMResourceToPathRangeDownload(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
target := filepath.Join("nested", "resource.bin")
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_range", "file_range", "file", target)
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_range", "file_range", "file", target, true)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -567,7 +567,7 @@ func TestDownloadIMResourceToPathInvalidContentRange(t *testing.T) {
}))
cmdutil.TestChdir(t, t.TempDir())
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_bad", "file_bad", "file", "out.bin")
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_bad", "file_bad", "file", "out.bin", true)
if err == nil || !strings.Contains(err.Error(), "invalid Content-Range header") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -596,7 +596,7 @@ func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_miderr", "file_miderr", "file", target)
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_miderr", "file_miderr", "file", target, true)
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -622,7 +622,7 @@ func TestDownloadIMResourceToPathRangeOverflowCleansOutput(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_overflow", "file_overflow", "file", target)
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_overflow", "file_overflow", "file", target, true)
if err == nil || !strings.Contains(err.Error(), "chunk overflow") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -658,7 +658,7 @@ func TestDownloadIMResourceToPathRangeShortChunkSizeMismatch(t *testing.T) {
cmdutil.TestChdir(t, t.TempDir())
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_short", "file_short", "file", "out.bin")
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_short", "file_short", "file", "out.bin", true)
if err == nil || !strings.Contains(err.Error(), "file size mismatch") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}

View File

@@ -593,7 +593,7 @@ func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
return nil, errors.New("http client unavailable")
}))
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin")
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin", true)
if err == nil || !strings.Contains(err.Error(), "http client unavailable") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -637,6 +637,68 @@ func TestParseTotalSize(t *testing.T) {
}
}
func TestParseContentDispositionFilename(t *testing.T) {
tests := []struct {
name string
header string
want string
}{
{name: "empty header", header: "", want: ""},
{name: "no filename param", header: "attachment", want: ""},
{name: "plain filename", header: `attachment; filename="report.xlsx"`, want: "report.xlsx"},
{name: "unquoted filename", header: `attachment; filename=report.xlsx`, want: "report.xlsx"},
{name: "RFC 5987 UTF-8 encoded", header: `attachment; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
{name: "RFC 5987 takes priority over plain", header: `attachment; filename="fallback.xlsx"; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
{name: "path traversal stripped", header: `attachment; filename="../../etc/passwd"`, want: "passwd"},
{name: "windows path stripped", header: `attachment; filename="C:\\Windows\\evil.exe"`, want: "evil.exe"},
{name: "control char rejected", header: "attachment; filename=\"evil\x01file.txt\"", want: ""},
{name: "malformed header", header: "not/valid/mime; ===", want: ""},
{name: "whitespace trimmed", header: `attachment; filename=" report.pdf "`, want: "report.pdf"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseContentDispositionFilename(tt.header); got != tt.want {
t.Fatalf("parseContentDispositionFilename(%q) = %q, want %q", tt.header, got, tt.want)
}
})
}
}
func TestResolveIMResourceDownloadPath(t *testing.T) {
tests := []struct {
name string
safePath string
contentType string
contentDisposition string
userSpecifiedOutput bool
want string
}{
// safePath already has extension: always return as-is
{name: "user path with ext, no CD", safePath: "out.xlsx", contentType: "application/pdf", userSpecifiedOutput: true, want: "out.xlsx"},
{name: "user path with ext, CD present", safePath: "out.xlsx", contentDisposition: `attachment; filename="server.pdf"`, userSpecifiedOutput: true, want: "out.xlsx"},
// No --output: use CD filename when present
{name: "default path, CD filename", safePath: "file_xxx", contentDisposition: `attachment; filename="季度报告.xlsx"`, want: "季度报告.xlsx"},
{name: "default path, CD RFC5987", safePath: "file_xxx", contentDisposition: `attachment; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
{name: "default path, no CD, MIME ext", safePath: "file_xxx", contentType: "application/pdf", want: "file_xxx.pdf"},
{name: "default path, no CD, unknown MIME", safePath: "file_xxx", contentType: "application/x-unknown", want: "file_xxx"},
{name: "default path, CD with dir component", safePath: "downloads/file_xxx", contentDisposition: `attachment; filename="report.xlsx"`, want: "downloads/report.xlsx"},
// User --output without extension: use CD filename's extension
{name: "user path no ext, CD with ext", safePath: "myfile", contentDisposition: `attachment; filename="server.pdf"`, userSpecifiedOutput: true, want: "myfile.pdf"},
{name: "user path no ext, CD no ext, MIME ext", safePath: "myfile", contentDisposition: `attachment; filename="noext"`, contentType: "image/png", userSpecifiedOutput: true, want: "myfile.png"},
{name: "user path no ext, no CD, MIME ext", safePath: "myfile", contentType: "image/jpeg", userSpecifiedOutput: true, want: "myfile.jpg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveIMResourceDownloadPath(tt.safePath, tt.contentType, tt.contentDisposition, tt.userSpecifiedOutput)
if got != tt.want {
t.Fatalf("resolveIMResourceDownloadPath() = %q, want %q", got, tt.want)
}
})
}
}
func TestShortcuts(t *testing.T) {
var commands []string
for _, shortcut := range Shortcuts() {

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strconv"
@@ -31,7 +32,7 @@ var ImMessagesResourcesDownload = common.Shortcut{
{Name: "message-id", Desc: "message ID (om_xxx)", Required: true},
{Name: "file-key", Desc: "resource key (img_xxx or file_xxx)", Required: true},
{Name: "type", Desc: "resource type (image or file)", Required: true, Enum: []string{"image", "file"}},
{Name: "output", Desc: "local save path (relative only, no .. traversal; defaults to file_key)"},
{Name: "output", Desc: "local save path (relative only, no .. traversal); when omitted, uses the server's Content-Disposition filename if available, otherwise file_key; extension is inferred from Content-Disposition or Content-Type if not provided"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileKey := runtime.Str("file-key")
@@ -72,7 +73,8 @@ var ImMessagesResourcesDownload = common.Shortcut{
return output.ErrValidation("unsafe output path: %s", err)
}
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath)
userSpecifiedOutput := runtime.Str("output") != ""
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath, userSpecifiedOutput)
if err != nil {
return err
}
@@ -262,18 +264,21 @@ func initialIMResourceDownloadHeaders(fileType string) map[string]string {
}
}
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string) (string, int64, error) {
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string, userSpecifiedOutput bool) (string, int64, error) {
downloadResp, err := doIMResourceDownloadRequest(ctx, runtime, messageID, fileKey, fileType, initialIMResourceDownloadHeaders(fileType))
if err != nil {
return "", 0, err
}
if downloadResp == nil {
return "", 0, output.ErrNetwork("download failed: empty response")
}
if downloadResp.StatusCode >= 400 {
defer downloadResp.Body.Close()
return "", 0, downloadResponseError(downloadResp)
}
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"))
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"), downloadResp.Header.Get("Content-Disposition"), userSpecifiedOutput)
var (
body io.ReadCloser
@@ -316,18 +321,63 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
return savedPath, result.Size(), nil
}
func resolveIMResourceDownloadPath(safePath, contentType string) string {
func resolveIMResourceDownloadPath(safePath, contentType, contentDisposition string, userSpecifiedOutput bool) string {
if filepath.Ext(safePath) != "" {
return safePath
}
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if cdFilename := parseContentDispositionFilename(contentDisposition); cdFilename != "" {
if !userSpecifiedOutput {
// No --output flag: use the original filename from the server.
dir := filepath.Dir(safePath)
if dir == "." {
return cdFilename
}
return filepath.Join(dir, cdFilename)
}
// User specified a path without extension: append the extension from the CD filename.
if ext := filepath.Ext(cdFilename); ext != "" {
return safePath + ext
}
}
mimeType := strings.TrimSpace(strings.Split(contentType, ";")[0])
if ext, ok := imMimeToExt[mimeType]; ok {
return safePath + ext
}
return safePath
}
// parseContentDispositionFilename extracts and sanitizes the filename from a
// Content-Disposition header. It handles RFC 5987 encoded filenames (filename*)
// with priority over plain filename via the standard mime package.
// Returns an empty string if no valid filename can be extracted.
func parseContentDispositionFilename(header string) string {
if header == "" {
return ""
}
_, params, err := mime.ParseMediaType(header)
if err != nil {
return ""
}
name := strings.TrimSpace(params["filename"])
if name == "" {
return ""
}
// Strip any path component (Unix or Windows style) to prevent path traversal.
if i := strings.LastIndexAny(name, "/\\"); i >= 0 {
name = name[i+1:]
}
if name == "" || name == "." || name == ".." {
return ""
}
// Reject control characters (including null bytes).
for _, r := range name {
if r < 0x20 || r == 0x7f {
return ""
}
}
return name
}
func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType string, headers map[string]string) (*http.Response, error) {
query := larkcore.QueryParams{}
query.Set("type", fileType)

View File

@@ -119,6 +119,29 @@ Drive Folder (云空间文件夹)
### 评论查询与统计口径(关键!)
**强制规则**`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到要包含已解决评论,仍然按默认口径查询未解决评论。
仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数。
**正确示例:**
```bash
# 默认查询:仅未解决评论(推荐)
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
# 查询所有评论(用户未明确要求包含已解决评论)
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
# 包含已解决评论(需用户明确要求)
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
```
**错误示例:**
```bash
# 不推荐:用户未明确要求但查询所有评论
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
```
- 查询文档评论时,使用 `drive file.comments list`。
- `drive file.comments list` 返回的 `items` 应理解为"评论卡片"列表,每个 `item` 对应用户界面里看到的一张评论卡片,而不是平铺的互动消息列表。
- 服务端语义上,创建第一条评论时会同时创建该卡片里的第一条 reply因此真正承载正文的是每个 `item.reply_list.replies`,其中第一条 reply 在用户视角下就是这张卡片里的"评论本身"。

View File

@@ -117,7 +117,6 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
<<<<<<< HEAD
- **发送前必须向用户确认收件人和内容;如有必要,可引导用户去飞书邮件里打开草稿查看详情;用户明确同意后才可执行发送或使用 `--confirm-send`**
- **发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)

View File

@@ -1,7 +1,7 @@
---
name: lark-base
version: 1.2.0
description: "当需要用 lark-cli 操作飞书多维表格Base时调用适用于建表、字段管理、记录读写、视图配置、历史查询以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
description: "当需要用 lark-cli 操作飞书多维表格Base时调用适用于建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
metadata:
requires:
bins: ["lark-cli"]
@@ -107,6 +107,7 @@ metadata:
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token``+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403 |
| `+record-delete / +record-history-list` | 删除记录,或查询某条记录的变更历史 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md)、[`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 删除时用户已明确目标可直接执行并带 `--yes`;历史查询按 `table-id + record-id`,不支持整表扫描;`+record-history-list` 只能串行执行 |
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
#### 2.3.4 View 子模块

View File

@@ -0,0 +1,72 @@
# base +record-share-link-create
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
为一条或多条记录生成分享链接(单次调用最多传入 100 条,内部调用批量接口)。
## 推荐命令
```bash
# 单条记录
lark-cli base +record-share-link-create \
--base-token xxx \
--table-id tbl_xxx \
--record-ids rec_xxx
# 多条记录(使用 "," 分隔)
lark-cli base +record-share-link-create \
--base-token xxx \
--table-id tbl_xxx \
--record-ids rec001,rec002,rec003
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id>` | 是 | 表 ID |
| `--record-ids <ids...>` | 是 | 记录 ID 列表,逗号分隔或重复使用该标志,最多 100 条 |
## API 入参详情
**HTTP 方法和路径:**
```http
POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/share_links/batch
```
**请求体:**
```json
{
"record_ids": ["rec001", "rec002", "rec003"]
}
```
> CLI 会自动对 `--record-ids` 去重后再调用接口。
## 返回重点
- 成功时直接返回接口 `data` 字段内容,包含 `record_share_links` 映射key 为 record_idvalue 为分享链接)。结构如下:
```json
{
"record_share_links": {
"rec001": "https://example.feishu.cn/record/TW2wrdbkoeoYXYcwvyIczJ2ZnFb"
}
}
```
- 若部分记录 ID 无权限/不存在,则 `record_share_links` 中只会包含有效记录对应的分享链接
- 若全部记录 ID 都无权限/不存在,则会返回错误信息 `records do not exist or no read permission`
## 坑点
- ⚠️ 单次最多 100 条记录,超出会被 CLI 校验拦截。
- ⚠️ 重复的 record_id 会在调用前自动去重。
- ⚠️ `--record-ids` 为空时会被校验拦截。
## 参考
- [lark-base-record.md](lark-base-record.md) — record 索引页

View File

@@ -17,6 +17,7 @@ record 相关命令索引。
| [lark-base-record-upload-attachment.md](lark-base-record-upload-attachment.md) | `+record-upload-attachment` | 上传本地文件到附件字段并更新记录 |
| [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md) | `lark-cli docs +media-download` | 下载 Base 附件到本地(附件的 `file_token` 来自 `+record-get` 的附件字段) |
| [lark-base-record-delete.md](lark-base-record-delete.md) | `+record-delete` | 删除记录 |
| [lark-base-record-share-link-create.md](lark-base-record-share-link-create.md) | `+record-share-link-create` | 生成记录分享链接(支持单条或批量,最多 100 条)|
## 说明

View File

@@ -8,14 +8,22 @@
## 重要说明
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
> **⚠️ `\n` 不是换行:** `--markdown "...\n..."` 里的 `\n` 在 shell 里是字面反斜杠 + n会作为文字写入文档。请用真实换行多行字符串、heredoc (`--markdown -`)、或 `$'...\n...'`bash/zsh。示例见下方。
## 命令
```bash
# 创建简单文档
lark-cli docs +create --title "项目计划" --markdown "## 目标\n\n- 目标 1\n- 目标 2"
lark-cli docs +create --title "项目计划" --markdown "## 目标
- 目标 1
- 目标 2"
# 创建到指定文件夹
lark-cli docs +create --title "会议纪要" --folder-token fldcnXXXX --markdown "## 讨论议题\n\n1. 进度\n2. 计划"
lark-cli docs +create --title "会议纪要" --folder-token fldcnXXXX --markdown "## 讨论议题
1. 进度
2. 计划"
# 创建到知识库节点下
lark-cli docs +create --title "技术文档" --wiki-node wikcnXXXX --markdown "## API 说明"
@@ -25,6 +33,19 @@ lark-cli docs +create --title "概览" --wiki-space 7000000000000000000 --markdo
# 创建到个人知识库
lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown "## 笔记"
# 从 stdin 读取(适合较长的 markdown 内容)
lark-cli docs +create --title "长文档" --markdown - <<'MD'
## 目标
- 目标 1
- 目标 2
## 计划
1. 第一步
2. 第二步
MD
```
## 返回值
@@ -116,13 +137,22 @@ wiki_space 可以从知识空间设置页面 URL 中获取,格式如:`https:
### 示例 1创建简单文档
```bash
lark-cli docs +create --title "项目计划" --markdown "## 项目概述\n\n这是一个新项目。\n\n## 目标\n\n- 目标 1\n- 目标 2"
lark-cli docs +create --title "项目计划" --markdown "## 项目概述
这是一个新项目。
## 目标
- 目标 1
- 目标 2"
```
### 示例 2使用飞书扩展语法
```bash
lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" background-color="light-blue">\n重要需求说明\n</callout>'
lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" background-color="light-blue">
重要需求说明
</callout>'
```
# 内容格式

View File

@@ -8,17 +8,23 @@
## 重要说明
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
> **⚠️ `\n` 不是换行:** `--markdown "...\n..."` 里的 `\n` 在 shell 里是字面反斜杠 + n会作为文字写入文档。请用真实换行多行字符串、heredoc (`--markdown -`)、或 `$'...\n...'`bash/zsh。示例见下方。
## 命令
```bash
# 追加内容
lark-cli docs +update --doc "<doc_id_or_url>" --mode append --markdown "## 新章节\n\n追加内容"
lark-cli docs +update --doc "<doc_id_or_url>" --mode append --markdown "## 新章节
追加内容"
# 定位替换(内容定位)
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_range --selection-by-title "## 功能说明" --markdown "## 功能说明
新内容"
# 全文替换
lark-cli docs +update --doc "<doc_id>" --mode replace_all --selection-with-ellipsis "张三" --markdown "李四"
@@ -38,7 +44,7 @@ lark-cli docs +update --doc "<doc_id>" --mode overwrite --markdown "# 全新内
# 同时更新标题
lark-cli docs +update --doc "<doc_id>" --mode append --markdown "## 更新日志" --new-title "文档 v2.0"
# 在指定内容后新增两个空白画板
# 在指定内容后新增两个空白画板ANSI-C 引用,适合短标签序列)
lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-ellipsis "有序列表" --markdown $'<whiteboard type="blank"></whiteboard>\n<whiteboard type="blank"></whiteboard>'
```
@@ -143,19 +149,25 @@ lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-elli
## append - 追加到末尾
```bash
lark-cli docs +update --doc "文档ID或URL" --mode append --markdown "## 新章节\n\n追加的内容..."
lark-cli docs +update --doc "文档ID或URL" --mode append --markdown "## 新章节
追加的内容..."
```
## replace_range - 定位替换
使用 `--selection-with-ellipsis`
```bash
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-with-ellipsis "## 旧标题...旧结尾。" --markdown "## 新标题\n\n新的内容..."
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-with-ellipsis "## 旧标题...旧结尾。" --markdown "## 新标题
新的内容..."
```
使用 `--selection-by-title`(替换整个章节):
```bash
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n更新后的内容..."
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明
更新后的内容..."
```
## replace_all - 全文替换
@@ -184,7 +196,9 @@ lark-cli docs +update --doc "文档ID" --mode delete_range --selection-by-title
⚠️ 会清空文档后重写,可能丢失图片、评论等,仅在需要完全重建文档时使用。
```bash
lark-cli docs +update --doc "文档ID" --mode overwrite --markdown "# 新文档\n\n全新的内容..."
lark-cli docs +update --doc "文档ID" --mode overwrite --markdown "# 新文档
全新的内容..."
```
## 创建空白画板

View File

@@ -132,6 +132,28 @@ Drive Folder (云空间文件夹)
### 评论查询与统计口径(关键!)
**强制规则**`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到要包含已解决评论,仍然按默认口径查询未解决评论。仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数。
**正确示例:**
```bash
# 默认查询:仅未解决评论(推荐)
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
# 查询所有评论(用户未明确要求包含已解决评论)
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
# 包含已解决评论(需用户明确要求)
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
```
**错误示例:**
```bash
# 不推荐:用户未明确要求但查询所有评论
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
```
- 查询文档评论时,使用 `drive file.comments list`。
- `drive file.comments list` 返回的 `items` 应理解为"评论卡片"列表,每个 `item` 对应用户界面里看到的一张评论卡片,而不是平铺的互动消息列表。
- 服务端语义上,创建第一条评论时会同时创建该卡片里的第一条 reply因此真正承载正文的是每个 `item.reply_list.replies`,其中第一条 reply 在用户视角下就是这张卡片里的"评论本身"。
@@ -196,7 +218,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
@@ -207,6 +229,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
## API Resources

View File

@@ -0,0 +1,77 @@
# drive +apply-permission申请文档权限
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli drive +apply-permission`
向云文档 **Owner** 发起 `view``edit` 权限申请。申请会以卡片形式推送给 Owner由 Owner 决定是否通过。
> [!CAUTION]
> 这是**写入操作** —— 会给 Owner 发推送通知,不要批量或自动化调用。可以先用 `--dry-run` 预览。
## 身份要求
- **仅支持 `user` 身份**(使用 `user_access_token`),不支持 `bot` / `tenant_access_token`shortcut 已在 `AuthTypes` 中强制限定为 `user`,使用 bot 会被拒。
- 所需 scope`docs:permission.member:apply`(若用户缺权限会走统一的 permission 错误路径)。
## 命令
```bash
# 通过 URL 申请type 自动从 URL 推断)
lark-cli drive +apply-permission \
--token "https://example.larksuite.com/docx/doxcnxxxxxxxxx" \
--perm view \
--remark "安全评估:需查看需求文档内容" --as user
# 通过 bare token + 显式 --type
lark-cli drive +apply-permission \
--token "doxcnxxxxxxxxx" --type docx \
--perm edit --as user
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | 目标文档 token 或完整 URL`/docx/``/sheets/``/base/``/bitable/``/file/``/wiki/``/doc/``/mindnote/``/slides/` 路径里的 token 会被自动提取) |
| `--type` | 否 | 目标类型,可选值 `doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `slides`。传 URL 时可由 shortcut 自动推断bare token 必须显式传 |
| `--perm` | 是 | 申请的权限,仅支持 `view``edit`**不支持 `full_access`**CLI 侧会直接拒绝) |
| `--remark` | 否 | 备注,会显示在权限申请卡片上 |
| `--dry-run` | 否 | 仅打印请求内容,不实际发送 |
## 输出
API 成功时返回空 `data`(仅 `code: 0, msg: "success"`),对应 CLI 输出:
```json
{
"ok": true,
"identity": "user",
"data": {}
}
```
## 频率限制
- **应用级**:每应用每租户每分钟最多 10 次。
- **用户级**:同一用户对**同一篇文档**一天不超过 5 次。
## 常见错误
| 错误码 | 含义 | CLI 处理 |
|---|---|---|
| `1063006` | 申请次数已达上限5 次/日) | CLI 自动加 hint`permission-apply quota reached: each user may request access on the same document at most 5 times per day` |
| `1063007` | 当前文档无法申请(如:文档禁用外部申请、申请者已拥有对应权限、目标类型不支持 apply | CLI 自动加 hint`this document does not accept a permission-apply request ... contact the owner directly` |
| `1063002` | 无操作权限(如该租户关闭了外部申请) | 由统一 permission 错误路径处理 |
| `1063004` | 用户所在组织无分享权限 | 由统一 permission 错误路径处理 |
| `1063005` | 资源已删除 | 需要确认目标文档/节点是否仍存在 |
| `1066001/1066002` | 服务端异常 / 并发冲突 | 稍后重试 |
## 与 wiki URL 的关系
传入 `/wiki/<node_token>`shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),自行先调 `wiki spaces get_node``obj_token + obj_type`,再用 bare token + `--type docx` 调本命令。
## 参考
- OpenAPI 端点:`POST /open-apis/drive/v1/permissions/:token/members/apply`

View File

@@ -34,7 +34,7 @@ lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_x
| `--message-id <id>` | Yes | Message ID (`om_xxx` format) |
| `--file-key <key>` | Yes | Resource key (`img_xxx` or `file_xxx`) |
| `--type <type>` | Yes | Resource type: `image` or `file` |
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed; defaults to `file_key` as the file name). File extension is automatically added based on Content-Type if not provided |
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed). When omitted, the server's original filename from `Content-Disposition` is used if available; otherwise defaults to `file_key`. File extension is automatically inferred from `Content-Disposition` or `Content-Type` if not provided |
| `--as <identity>` | No | Identity type: `user` (default) or `bot` |
| `--dry-run` | No | Print the request only, do not execute it |
@@ -51,7 +51,7 @@ When downloading large files, the command automatically uses **HTTP Range reques
**Benefits:**
- Reduces the impact of transient request failures during large downloads
- Automatically detects and appends correct file extension from Content-Type
- Preserves the server's original filename via `Content-Disposition` (supports RFC 5987 UTF-8 encoding); falls back to `Content-Type`-based extension inference
- Validates file size integrity after download completion
## `file_key` Sources

View File

@@ -132,7 +132,6 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
<<<<<<< HEAD
- **发送前必须向用户确认收件人和内容;如有必要,可引导用户去飞书邮件里打开草稿查看详情;用户明确同意后才可执行发送或使用 `--confirm-send`**
- **发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)
@@ -415,7 +414,6 @@ lark-cli mail <resource> <method> [flags] # 调用 API
### user_mailbox.settings
- `get_signatures` — 获取用户邮箱签名列表
- `send_as` — 获取账号的所有可发信地址,包括主地址、别名地址、邮件组。可以使用用户地址访问该接口,也可以使用用户有权限的公共邮箱地址访问该接口。
### user_mailbox.threads
@@ -477,7 +475,6 @@ lark-cli mail <resource> <method> [flags] # 调用 API
| `user_mailbox.rules.list` | `mail:user_mailbox.rule:read` |
| `user_mailbox.rules.reorder` | `mail:user_mailbox.rule:write` |
| `user_mailbox.rules.update` | `mail:user_mailbox.rule:write` |
| `user_mailbox.settings.get_signatures` | `mail:user_mailbox:readonly` |
| `user_mailbox.settings.send_as` | `mail:user_mailbox:readonly` |
| `user_mailbox.threads.batch_modify` | `mail:user_mailbox.message:modify` |
| `user_mailbox.threads.batch_trash` | `mail:user_mailbox.message:modify` |

View File

@@ -4,6 +4,18 @@
**用户 prompt 简短/模糊时**(如"画个漏斗图"、"画个架构图"),不要只输出字面内容。应适当补充该领域合理的内容
## 图片需求识别
> **在规划内容之前,先判断是否需要插入真实图片。**
**触发条件(严格)**:仅当用户**显式说了**「图片、配图、插图、照片、真实图片、实拍」等词时,才使用 image 节点。
**不触发**:即使主题是旅行、美食、产品等视觉性内容,只要用户没显式要求图片,就不使用 image 节点,用文字 + 形状 + icon 呈现。
**识别到图片需求后**:参考 [`references/image.md`](image.md) 完成 Step 0图片准备再回来继续内容规划。
**图片数量规划**3-6 张为宜。少于 3 张显得单薄,多于 6 张增加准备时间且布局拥挤。
## 信息量参考
| 用户需求 | 合理的信息量 |

View File

@@ -0,0 +1,57 @@
# 图片资源处理
## 图片需求识别
**触发条件(严格)**:仅当用户**显式要求**使用图片时,才使用 image 节点。触发关键词:
> 图片、配图、插图、照片、真实图片、实拍、放一张图、加个图、嵌入图片
**不触发的情况**:即使主题涉及旅行、美食、产品、人物等视觉性内容,只要用户没有显式说要「图片/配图/插图」,就**一律不使用 image 节点**,用文字 + 形状 + icon 来呈现即可。
识别到图片需求后,先完成下方 Step 0再回到 [DSL 路径 Workflow](../routes/dsl.md) 继续 Step 2生成 DSL
**图片数量**3-6 张为宜。
## Step 0图片准备
1. 识别图片需求(见上方触发关键词表)
2. 确定需要几张图,为每张图准备不同的搜索关键词(英文)
3. 逐张下载 → 校验每张图不同(文件大小) → 逐张上传到飞书 Drive
4. 收集所有 file_token在 Step 2 生成 DSL 时引用
## 上传步骤
**单张图片**
```bash
curl -L -o palace.jpg "https://example.com/palace.jpg"
lark-cli drive +upload --file ./palace.jpg
# 响应: { "file_token": "<file_token>", ... }
```
**多张图片(每张必须是不同的图)**
```bash
# 1. 每张图用不同的搜索词/URL 下载
curl -L -o forbidden-city.jpg "https://example.com/forbidden-city.jpg"
curl -L -o great-wall.jpg "https://example.com/great-wall.jpg"
curl -L -o temple.jpg "https://example.com/temple.jpg"
# 2. 校验每张图确实不同(比较文件大小,跨平台通用)
ls -l *.jpg # 确认每张文件大小不同;若大小相同则内容可能重复,需重新下载
# 3. 逐张上传,收集 token
lark-cli drive +upload --file ./forbidden-city.jpg # → <file_token_1>
lark-cli drive +upload --file ./great-wall.jpg # → <file_token_2>
lark-cli drive +upload --file ./temple.jpg # → <file_token_3>
```
> **多图常见错误**:用同一个 URL 参数下载多次,导致多张图片完全相同。每张图必须用不同的搜索关键词或不同的图片 ID。
## 图片来源策略
| 来源 | 方式 | 适用场景 |
|------|------|----------|
| 公开 URL | `curl -L -o file.jpg <URL>` 下载后上传 | 景点照片、开源图片 |
| AI 生成 | 调用图片生成工具,保存后上传 | 插画、图标、概念图 |
| 用户提供 | 用户给出本地路径或 URL | 产品截图、Logo |
> `image.src` 必须是飞书 Drive 的 `file_token`,不支持直接使用 URL。所有图片都需要先上传。

View File

@@ -372,3 +372,14 @@ npx -y @larksuite/whiteboard-cli@^0.2.0 -i skeleton.json -o step1.png -l coords.
```
`alignItems: 'stretch'` + `width: 'fill-container'` = 等宽等高。
---
## 图文卡片
含图片的画板用图文卡片布局vertical frame图上文下
- image 宽度 = 卡片宽度height 按 3:2 比例(如 240×160
- 卡片间 gap: 24比纯文字间距大
- 多卡片一行超过 3 张时,换行用嵌套 horizontal frame
- 详见 `scenes/photo-showcase.md`

View File

@@ -237,6 +237,81 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
"svg": { "code": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/></svg>" } }
```
### Image图片节点
在画板中嵌入图片。图片需先上传到飞书 Drive 获取 `file_token`
```typescript
{
type: 'image';
id?: string;
x?: number; y?: number;
opacity?: number; // 0-1
width: WBSizeValue;
height: WBSizeValue;
image: { src: string }; // 飞书 Drive file_token
}
```
#### 使用要求
图片必须先上传到飞书 Drive`image.src` 填写返回的 `file_token`(如 `"T8SBbLB5co85YLxuX8icHAlrnZg"`)。
**上传步骤**
```bash
# 1. 上传图片到飞书 Drive
lark-cli drive +upload --file ./beijing-palace.jpg
# 2. 从响应中提取 file_token填入 DSL
```
#### 尺寸建议
| 用途 | 推荐尺寸 | 说明 |
|------|----------|------|
| 卡片插图 | 200×150 ~ 300×200 | 配合文字卡片使用 |
| 全宽背景 | 与 frame 同宽,高度按比例 | 用于地图、背景板 |
| 缩略图/头像 | 60×60 ~ 100×100 | 列表项中的小图 |
> **宽高比**:建议保持原始图片的宽高比,避免拉伸变形。如果不确定原始比例,使用正方形(如 200×200
#### 典型用法
**1. 图文卡片**(图片 + 文字描述)
```json
{
"type": "frame", "layout": "vertical", "gap": 8, "padding": 0,
"width": 240, "height": "fit-content",
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
"children": [
{ "type": "image", "width": 240, "height": 160,
"image": { "src": "<file_token>" } },
{ "type": "frame", "layout": "vertical", "gap": 4, "padding": [8, 12, 12, 12],
"width": "fill-container", "height": "fit-content",
"children": [
{ "type": "text", "text": "故宫博物院", "fontSize": 14, "width": "fill-container", "height": "fit-content" },
{ "type": "text", "text": "世界最大的古代宫殿建筑群", "fontSize": 11, "textColor": "#666666", "width": "fill-container", "height": "fit-content" }
]
}
]
}
```
**2. 图片网格**(多张图片平铺)
```json
{
"type": "frame", "layout": "horizontal", "gap": 16, "padding": 16,
"width": "fit-content", "height": "fit-content",
"children": [
{ "type": "image", "width": 200, "height": 150, "image": { "src": "<token_1>" } },
{ "type": "image", "width": 200, "height": 150, "image": { "src": "<token_2>" } },
{ "type": "image", "width": 200, "height": 150, "image": { "src": "<token_3>" } }
]
}
```
### Icon内置图标
引用画板内置图标库的图标。比手写 SVG 更简单——只需指定 `name`

View File

@@ -73,6 +73,7 @@ Step 3: 渲染 & 审查 → 交付
| 循环/飞轮图 | `scenes/flywheel.md` | 增长飞轮、闭环链路 |
| 里程碑 | `scenes/milestone.md` | 时间线、版本演进 |
| 流程图 | `scenes/flowchart.md` | 业务流、状态机、带条件判断的链路 |
| 图片展示 | `scenes/photo-showcase.md` | 用户显式要求图片/配图/插图时 |
## 渲染前自查

View File

@@ -0,0 +1,122 @@
# 图片展示 (Photo Showcase)
适用于:用户**显式要求使用图片/配图/插图**的场景(如"画一个带配图的旅行路线"、"做一个有图片的产品展示")。
> **注意**:仅当用户明确说了「图片/配图/插图/照片」等词时才进入本场景。单纯说"旅行路线图"、"产品展示"等不触发。
> **前置条件**:进入本场景前,必须已完成 [`references/image.md`](../references/image.md) 的 Step 0图片准备拿到所有 file_token。
## Content 约束
- 图片 3-6 张,每张配标题(必需)+ 简短描述可选15字内
- **每张图必须是不同的真实图片**(不同 file_token下载时用不同关键词/URL
- 下载后用 `md5` 校验确保每张图不重复
- 文字仅作辅助说明,图片是信息主体
## Layout 选型
| 模式 | 适用条件 | 特征 |
|------|---------|------|
| **卡片网格(默认)** | 多图平级展示(产品墙、团队介绍、美食推荐) | horizontal frame 内放等尺寸图文卡片 |
| **路线时间线** | 有先后顺序(旅行路线、团建路线、项目演进) | 图文卡片 + connector 串联 |
| **中心辐射** | 有一个核心主题 + 周围子项 | 中心标题 + 周围图文卡片 |
## Layout 规则
- **图文卡片结构**vertical frame图上文下image 宽度 = 卡片宽度height 按 3:2 比例
- **卡片统一尺寸**:所有卡片宽高一致(推荐 240×280 或 200×250
- **图片统一尺寸**:所有 image 节点用相同 width/height推荐 240×160 或 200×133
- **卡片间距**gap: 24比纯文字图表间距更大让图片呼吸
- **卡片样式**:白色底 + 圆角 12 + 细边框image 无圆角(紧贴卡片顶部)
- **有序路线时**:卡片间用 connector 连接connector 放顶层 nodes 数组
## 骨架示例
### 卡片网格(产品展示/团队介绍/美食推荐)
```json
{
"version": 2,
"nodes": [
{
"type": "frame", "id": "grid", "layout": "vertical", "gap": 24, "padding": 32,
"width": 840, "height": "fit-content",
"children": [
{ "type": "text", "id": "title", "width": 776, "height": 36,
"text": "图表标题", "fontSize": 24, "textAlign": "center" },
{
"type": "frame", "id": "row", "layout": "horizontal", "gap": 24, "padding": 0,
"width": "fit-content", "height": "fit-content",
"children": [
{
"type": "frame", "id": "card-1", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
"width": 240, "height": "fit-content",
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
"children": [
{ "type": "image", "id": "img-1", "width": 240, "height": 160, "image": { "src": "<token_1>" } },
{ "type": "text", "id": "t-1", "text": "标题", "fontSize": 14, "width": 216, "height": 20 },
{ "type": "text", "id": "d-1", "text": "简短描述", "fontSize": 11, "textColor": "#666666", "width": 216, "height": 16 }
]
}
]
}
]
}
]
}
```
每张图文卡片结构相同,复制并替换 `<token_N>`、标题和描述即可。3 张卡片一行,超过 3 张换行(嵌套第二个 horizontal frame
### 路线时间线(旅行路线/团建路线)
```json
{
"version": 2,
"nodes": [
{
"type": "frame", "id": "route", "layout": "vertical", "gap": 24, "padding": 32,
"width": 1100, "height": "fit-content",
"children": [
{ "type": "text", "id": "title", "width": 1036, "height": 36,
"text": "路线标题", "fontSize": 24, "textAlign": "center" },
{
"type": "frame", "id": "stops", "layout": "horizontal", "gap": 32, "padding": 0,
"width": "fit-content", "height": "fit-content",
"children": [
{
"type": "frame", "id": "stop-1", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
"width": 240, "height": "fit-content",
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
"children": [
{ "type": "image", "id": "img-1", "width": 240, "height": 160, "image": { "src": "<token_1>" } },
{ "type": "text", "id": "t-1", "text": "第1站地点名", "fontSize": 14, "width": 216, "height": 20 }
]
},
{
"type": "frame", "id": "stop-2", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
"width": 240, "height": "fit-content",
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
"children": [
{ "type": "image", "id": "img-2", "width": 240, "height": 160, "image": { "src": "<token_2>" } },
{ "type": "text", "id": "t-2", "text": "第2站地点名", "fontSize": 14, "width": 216, "height": 20 }
]
}
]
}
]
},
{ "type": "connector", "id": "c1", "connector": { "from": "stop-1", "to": "stop-2", "fromAnchor": "right", "toAnchor": "left" } }
]
}
```
注意connector 必须放在**顶层 nodes 数组**,不能嵌套在 frame.children 内。connector 的属性须包裹在 `connector` 字段中。
## 图片准备检查清单
生成 DSL 前确认:
- [ ] 所有 image 节点的 `image.src` 都是已上传的 file_token非 URL
- [ ] 每个 file_token 不同(对应不同的真实图片)
- [ ] 所有图片尺寸一致(同一画板内统一 width×height
- [ ] 图片宽高比合理(推荐 3:2即 240×160

View File

@@ -1,20 +1,22 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 28 leaf commands
- Covered: 1
- Coverage: 3.6%
- Denominator: 29 leaf commands
- Covered: 2
- Coverage: 6.9%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
- Blocked area: upload, export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
- Blocked area: upload, export, comment, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. Permission flows are partially covered via the dry-run test for `+apply-permission`; the full permission.members.* API surface remains uncovered.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✕ | drive +add-comment | shortcut | | none | no comment workflow yet |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✕ | drive +export | shortcut | | none | no export workflow yet |

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_ApplyPermissionDryRun locks in the request shape the shortcut
// emits under --dry-run: the real CLI binary is invoked end-to-end (so the
// full flag-parsing, validation, and dry-run renderers all execute), and the
// printed request is inspected to confirm
// - HTTP method, URL template, and the token path segment,
// - type query parameter (auto-inferred from a URL input, explicit for a
// bare token input),
// - perm / remark body fields.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestDrive_ApplyPermissionDryRun(t *testing.T) {
// Isolate from any local CLI state: the subprocess inherits the parent
// test environment, and without an explicit config dir it could read a
// developer's real credentials/profile instead of the fake ones below.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURL string
wantType string
wantPerm string
wantBody map[string]string // optional substrings (key=rendered token) to require
}{
{
name: "URL input auto-infers docx type",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
"--perm", "view",
"--remark", "e2e note",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members/apply",
wantType: "docx",
wantPerm: "view",
wantBody: map[string]string{"remark": "e2e note"},
},
{
name: "URL input auto-infers sheet type",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/sheets/shtcnE2E002?sheet=abc",
"--perm", "edit",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/shtcnE2E002/members/apply",
wantType: "sheet",
wantPerm: "edit",
},
{
// Explicit --type must override URL inference: the /docx/ marker
// would infer type=docx, but the caller asked for type=wiki (e.g.
// to apply against the underlying wiki node rather than its docx
// target). The URL token itself is still used as the path token.
name: "explicit --type overrides URL inference",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/docx/doxcnE2E003",
"--type", "wiki",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E003/members/apply",
wantType: "wiki",
wantPerm: "view",
},
{
name: "bare token with explicit type",
args: []string{
"drive", "+apply-permission",
"--token", "bscE2E004",
"--type", "bitable",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/bscE2E004/members/apply",
wantType: "bitable",
wantPerm: "view",
},
{
name: "slides URL inference",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/slides/slE2E004",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/slE2E004/members/apply",
wantType: "slides",
wantPerm: "view",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
// Dry-run output is the JSON envelope; gjson walks into api[0].
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
}
if got := gjson.Get(out, "api.0.body.perm").String(); got != tt.wantPerm {
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
}
for k, v := range tt.wantBody {
if got := gjson.Get(out, "api.0.body."+k).String(); got != v {
t.Fatalf("body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
// When no --remark is passed, the body must NOT carry an empty
// remark field (the owner's request card would otherwise render
// a blank note).
if _, wantsRemark := tt.wantBody["remark"]; !wantsRemark {
if gjson.Get(out, "api.0.body.remark").Exists() {
t.Fatalf("body.remark should be omitted when --remark is empty, stdout:\n%s", out)
}
}
})
}
}
// TestDrive_ApplyPermissionDryRunRejectsFullAccess locks in the client-side
// enum guard: the spec rejects perm=full_access, so the shortcut must refuse
// it before the request ever reaches the server. Exercised end-to-end to
// guarantee the enum validator is wired into the mount path.
func TestDrive_ApplyPermissionDryRunRejectsFullAccess(t *testing.T) {
// Isolate from any local CLI state: the subprocess inherits the parent
// test environment, and without an explicit config dir it could read a
// developer's real credentials/profile instead of the fake ones below.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+apply-permission",
"--token", "doxcnE2E999",
"--type", "docx",
"--perm", "full_access",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("full_access must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "perm") {
t.Fatalf("expected perm-related error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}