Compare commits

...

9 Commits

Author SHA1 Message Date
fangshuyu-768
aea9f37f58 feat(wiki): add exponential backoff retry for +node-create lock contention (#1012) (#1076)
When creating wiki nodes under the same parent concurrently, the API
returns error code 131009 (lock contention) ~5-15% of the time. This
adds automatic retry with exponential backoff (250ms, 500ms; max 2
retries) so callers no longer need to implement retry logic themselves.

- Retry loop in runWikiNodeCreate: only retries on code 131009, respects
  context cancellation, prints progress to stderr
- wrapWikiNodeCreateRetryError preserves Err/Raw/Detail.Code in ExitError
- 6 unit tests covering retry success, exhaustion, non-contention error,
  single-retry success, context cancellation, no-retry on success
- 8 dry-run E2E tests for wiki +node-create request shape and validation
2026-05-25 20:03:17 +08:00
liujinkun2025
ac06eaa0f4 fix(wiki): rename +node-get --token to --node-token, keep alias (#1074)
Per issue #1049 (third point), wiki +node-get used --token while sibling
commands (+node-delete / +node-copy / +move) use --node-token. The
inconsistency forced humans and AI agents to remember which adjacent
command takes which flag.

Make --node-token the canonical flag and keep --token as a hidden,
deprecated alias so existing scripts continue to work. pflag's
MarkDeprecated prints "Flag --token has been deprecated, use --node-token
instead" to stderr on use, guiding callers to migrate. Conflict between
the two with different values is rejected upfront.

Skills docs (lark-wiki, lark-base) updated to prefer --node-token.

Change-Id: I3415a98f079613c0b1a0b989cf54a09cbb8986fb
2026-05-25 17:28:45 +08:00
fangshuyu-768
282c27784d docs(skills): add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073) 2026-05-25 17:15:50 +08:00
郭立lee
f2a4c95665 fix(output): classify wiki lock-contention error (131009) with retry hint (#1014)
Wiki write-path operations (most commonly `wiki +node-create` against the
same parent) surface code 131009 "lock contention" under concurrent calls.
Currently this falls through to the generic "api_error" classification,
giving users no hint that it is transient and safe to retry.

Mirror the existing `LarkErrDriveResourceContention` (1061045) treatment:
add a named constant, classify as "conflict", and emit a hint that points
the caller toward exponential backoff or serializing sibling-node writes.

Refs: #1012
2026-05-25 15:50:45 +08:00
JackZhao10086
cb5055eb46 feat(auth): add auth qrcode subcommand and update auth docs/hints (#968)
* feat(auth): add auth qrcode subcommand and update auth docs/hints

* refactor(auth/qrcode): improve qrcode command with validation and custom output
2026-05-25 15:34:00 +08:00
WJzz1
9d4233bfe3 fix(contact): add actionable hint when fanout search all-fail with no API code (#1054)
In buildFanoutResponse, when every fanout query fails AND the first failure
has no Lark API code (i.e. transport, parse, panic, or context-cancel),
the returned ExitError was carrying an empty Hint. This is the only
output.ErrWithHint call in shortcuts/ that ships an empty hint.

AGENTS.md states: "every error message you write will be parsed by an AI
to decide its next action. Make errors structured, actionable, and
specific." An empty hint gives the agent nothing to do.

Populate the hint with the actionable next step for this branch — retry,
and if it persists, narrow --queries to a single term to isolate the
failing input. The companion test exercises the no-code path and asserts
the hint is non-empty and mentions "retry".

Co-authored-by: Wang-Yeah623 <Wang-Yeah623@users.noreply.github.com>
2026-05-25 12:08:18 +08:00
zed
708cbc2b31 fix: use ErrValidation instead of fmt.Errorf in Validate paths (#1001)
Replace 8 bare fmt.Errorf calls with output.ErrValidation across 3 files
so validation errors consistently return structured JSON (type: validation,
exit 2) matching the rest of the codebase.

Affected functions: validateExpectedFlag (sheets), validateSendTime,
validateComposeInlineAndAttachments, validateEventFlags (mail),
validateSignatureWithPlainText (mail)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:52:51 +08:00
ZEden0
6d1f9980fa fix: annotate auto-grant permission failures with required_scope and console_url (#1045)
When AutoGrantCurrentUserDrivePermission encounters lark code 99991672/99991679,
extract permission_violations from the underlying ExitError and surface
lark_code, required_scope, and console_url on the result map. Override the
generic fallback hint with one pointing at the developer console — the
concrete next step a user can take.

Refactor extractRequiredScopes / SelectRecommendedScope wrapping / console URL
construction out of cmd/root.go into internal/registry/scope_hint.go so both
the top-level enrichPermissionError path and the best-effort sub-call path in
shortcuts/common share one implementation.

Change-Id: Ida63ed160d1167b7961b6faac5c2cf9b7f971c65
2026-05-25 11:01:01 +08:00
zero-my
6e3e120ec8 Docs/lark task shortcut doc refresh (#1057)
* docs: align lark-task attachment descriptions

* docs: restore lark-task attachment capability summary
2026-05-24 00:32:28 +08:00
58 changed files with 1956 additions and 174 deletions

View File

@@ -43,6 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}

View File

@@ -61,7 +61,7 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
for _, want := range []string{
"only delivers final turn messages",
"--no-wait --json",
"send the verification URL to the user as your final message",
"send the verification URL (or QR code) to the user as your final message",
"run --device-code in a later step",
} {
if !strings.Contains(got, want) {

View File

@@ -47,9 +47,10 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
Long: `Device Flow authorization login.
For AI agents: this command blocks until the user completes authorization in the
browser. If your harness only delivers final turn messages, use --no-wait --json,
send the verification URL to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization.`,
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
to generate QR codes (supports ASCII and PNG formats).`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return output.ErrWithHint(output.ExitValidation, "command_denied",
@@ -275,7 +276,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -458,6 +459,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return nil
}
// syncLoginUserToProfile persists the logged-in user info into the named profile.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
@@ -483,6 +485,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return nil
}
// findProfileByName returns the AppConfig matching profileName, or nil.
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {

View File

@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用包含该 URL 的代码块单独输出。",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示,确保用户可扫码或复制链接。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改(包括 URL 编码/解码、添加空格或标点),建议用包含该 URL 的代码块单独输出。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
@@ -114,6 +114,7 @@ var loginMsgEn = &loginMsg{
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn

View File

@@ -945,12 +945,20 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"exactly as returned by the CLI",
"MUST generate QR code AND display it",
"lark-cli auth qrcode",
"Prefer PNG QR code (--output)",
"use ASCII (--ascii) only when the user explicitly requests it",
"This is a required step, do NOT skip it",
"CRITICAL",
"You MUST include the QR image in your response",
"Generating the file alone is NOT enough",
"image tags, inline images, or file attachments",
"Display order",
"place the QR code image below the URL",
"opaque string",
"Do not URL-encode or decode it",
"do not add %20, spaces, or punctuation",
"do not wrap it as Markdown link text",
"fenced code block containing only the raw URL",
"cannot be modified",
"Prefer a fenced code block",
"final message of the turn",
"return control to the user",
"do not block on --device-code in the same turn",
@@ -1054,12 +1062,18 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
"结束本轮",
"用户回复已完成授权",
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
"逐字原样转发 CLI 返回的 URL",
"必须生成二维码并展示",
"lark-cli auth qrcode",
"优先生成 PNG 二维码(--output",
"仅当用户明确要求时才使用 ASCII--ascii",
"生成后必须在回复中展示图片",
"仅生成文件不算完成",
"image 标签或内联图片",
"二维码图片置于 URL 下方完整展示",
"URL 输出规则",
"opaque string",
"不要做 URL 编码或解码",
"不要补 `%20`、空格或标点",
"不要改写成 Markdown 链接",
"只包含该 URL 的代码块单独输出",
"不要做任何修改",
"仅包含该 URL 的代码块",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)

142
cmd/auth/qrcode.go Normal file
View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/skip2/go-qrcode"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// QRCodeOptions holds inputs for auth qrcode command.
type QRCodeOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
URL string
Size int
ASCII bool
Output string
}
// NewCmdAuthQRCode creates the auth qrcode subcommand.
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
opts := &QRCodeOptions{Factory: f, Size: 256}
cmd := &cobra.Command{
Use: "qrcode <url>",
Short: "Generate QR code for verification URL",
Long: `Generate a QR code image or ASCII representation for a verification URL.
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
For ASCII output, the result is printed to stdout with fixed size.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.URL = args[0]
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
return runQRCode(opts)
},
}
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
return cmd
}
// runQRCode executes the auth qrcode command.
func runQRCode(opts *QRCodeOptions) error {
if opts.URL == "" {
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
}
if opts.ASCII {
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
return generateASCIIQRCode(opts.URL, out)
}
if opts.Output == "" {
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
}
if opts.Size < 32 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
}
if opts.Size > 1024 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
}
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
return err
}
result := map[string]interface{}{
"ok": true,
"file_path": safePath,
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
}
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
}
return nil
}
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
func generateImageQRCode(url string, size int, outputPath string) error {
png, err := qrcode.Encode(url, qrcode.Medium, size)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
}
err = vfs.WriteFile(outputPath, png, 0644)
if err != nil {
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
}
return nil
}
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
func generateASCIIQRCode(url string, w io.Writer) error {
q, err := qrcode.New(url, qrcode.Medium)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
}
fmt.Fprint(w, q.ToSmallString(false))
return nil
}

368
cmd/auth/qrcode_test.go Normal file
View File

@@ -0,0 +1,368 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.URL != "https://example.com" {
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
}
if gotOpts.Size != 128 {
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
}
if gotOpts.Output != "qr.png" {
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
}
if gotOpts.ASCII {
t.Error("ASCII should be false by default")
}
}
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.ASCII {
t.Error("ASCII should be true when --ascii is passed")
}
}
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Size != 256 {
t.Errorf("default Size = %d, want 256", gotOpts.Size)
}
}
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when no URL argument provided")
}
}
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
var result map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
hint, _ := result["hint"].(string)
if hint == "" {
t.Error("hint is empty")
}
if !strings.Contains(hint, "MUST include") {
t.Errorf("hint missing 'MUST include', got: %s", hint)
}
if !strings.Contains(hint, "NOT enough") {
t.Errorf("hint missing 'NOT enough', got: %s", hint)
}
}
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"https://example.com"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when --output is missing in PNG mode")
}
}
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetOut(stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := stdout.String()
for _, want := range []string{
"qrcode <url>",
"QR code",
"--output",
"--ascii",
"relative path",
} {
if !strings.Contains(got, want) {
t.Errorf("help missing %q", want)
}
}
}
func TestRunQRCode_MissingURL(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: ""})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_url" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
}
}
func TestRunQRCode_MissingOutput(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_output" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
}
}
func TestRunQRCode_InvalidSize(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 16,
Output: "qr.png",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_SizeTooLarge(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 2048,
Output: "qr.png",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "/etc/passwd",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}
func TestRunQRCode_PNGWritesFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "qr.png",
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
info, err := os.Stat("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if info.Size() == 0 {
t.Error("output file is empty")
}
var result map[string]interface{}
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
}
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
ASCII: true,
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stdout.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateImageQRCode_Success(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "test-qr.png")
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if len(data) == 0 {
t.Error("output file is empty")
}
if len(data) < 8 {
t.Error("output too small to be a valid PNG")
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
}
func TestGenerateImageQRCode_WriteError(t *testing.T) {
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
if err == nil {
t.Fatal("expected error writing to nonexistent directory")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if exitErr.Detail.Type != "write_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
}
}
func TestGenerateASCIIQRCode_Success(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("https://example.com", &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("", &buf)
if err == nil {
t.Fatal("expected error for empty string")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "encode_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
}
}

View File

@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"sort"
"strconv"
@@ -389,8 +388,8 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
return
}
// Extract required scopes from API error detail
scopes := extractRequiredScopes(exitErr.Detail.Detail)
// Extract required scopes from API error detail (shared helper)
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
@@ -401,21 +400,10 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
}
// Select the recommended (least-privilege) scope
scopeIfaces := make([]interface{}, len(scopes))
for i, s := range scopes {
scopeIfaces[i] = s
}
recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant")
if recommended == "" {
recommended = scopes[0]
}
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
// Build admin console URL with the recommended scope
host := "open.feishu.cn"
if cfg.Brand == "lark" {
host = "open.larksuite.com"
}
consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended))
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
// Clear raw API detail — useful info is now in message/hint/console_url
exitErr.Detail.Detail = nil
@@ -452,26 +440,3 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
exitErr.Detail.ConsoleURL = consoleURL
}
}
// extractRequiredScopes extracts scope names from the API error's permission_violations field.
func extractRequiredScopes(detail interface{}) []string {
m, ok := detail.(map[string]interface{})
if !ok {
return nil
}
violations, ok := m["permission_violations"].([]interface{})
if !ok {
return nil
}
var scopes []string
for _, v := range violations {
vm, ok := v.(map[string]interface{})
if !ok {
continue
}
if subject, ok := vm["subject"].(string); ok {
scopes = append(scopes, subject)
}
}
return scopes
}

View File

@@ -39,6 +39,10 @@ const (
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
// Wiki write-path lock contention (e.g. concurrent wiki +node-create under the
// same parent). Server-side write lock; transient, safe to retry with backoff.
LarkErrWikiLockContention = 131009
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
@@ -83,6 +87,8 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrWikiLockContention:
return ExitAPI, "conflict", "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:

View File

@@ -90,3 +90,24 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
})
}
}
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
// contention error (131009) maps to an actionable retry hint instead of
// a generic "api_error". Surfaces during concurrent wiki +node-create
// against the same parent (see larksuite/cli#1012).
func TestClassifyLarkError_WikiLockContention(t *testing.T) {
t.Parallel()
gotExitCode, gotType, gotHint := ClassifyLarkError(LarkErrWikiLockContention, "raw msg")
if gotExitCode != ExitAPI {
t.Fatalf("exitCode=%d, want %d", gotExitCode, ExitAPI)
}
if gotType != "conflict" {
t.Fatalf("type=%q, want %q", gotType, "conflict")
}
if !strings.Contains(gotHint, "wiki write lock") {
t.Fatalf("hint=%q, want substring %q", gotHint, "wiki write lock")
}
if !strings.Contains(gotHint, "backoff") {
t.Fatalf("hint=%q, want substring %q", gotHint, "backoff")
}
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"fmt"
"net/url"
"github.com/larksuite/cli/internal/core"
)
// ExtractRequiredScopes pulls scope names out of the API error's
// permission_violations field. The detail argument is the raw `error` block
// that the platform returns alongside lark code 99991672 / 99991679 — typically
// shaped as:
//
// { "permission_violations": [ {"subject": "<scope>"}, ... ] }
//
// Returns nil when the structure does not match or no non-empty subjects are
// present, so callers can branch on a simple len() == 0 check.
func ExtractRequiredScopes(detail interface{}) []string {
m, ok := detail.(map[string]interface{})
if !ok {
return nil
}
violations, ok := m["permission_violations"].([]interface{})
if !ok {
return nil
}
scopes := make([]string, 0, len(violations))
for _, v := range violations {
vm, ok := v.(map[string]interface{})
if !ok {
continue
}
if subject, ok := vm["subject"].(string); ok && subject != "" {
scopes = append(scopes, subject)
}
}
if len(scopes) == 0 {
return nil
}
return scopes
}
// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper
// around SelectRecommendedScope. When no scope is recognized by the priority
// table, it falls back to the first input scope so callers always have
// something to surface to users.
func SelectRecommendedScopeFromStrings(scopes []string, identity string) string {
if len(scopes) == 0 {
return ""
}
ifaces := make([]interface{}, len(scopes))
for i, s := range scopes {
ifaces[i] = s
}
if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" {
return recommended
}
return scopes[0]
}
// BuildConsoleScopeURL returns the developer-console "apply scope" URL for the
// given app and scope, branded for feishu / lark. Returns "" when appID or
// scope is empty so callers can omit the field cleanly.
func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" {
return ""
}
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf(
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
url.QueryEscape(appID),
url.QueryEscape(scope),
)
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestExtractRequiredScopes_HappyPath(t *testing.T) {
detail := map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
map[string]interface{}{"subject": "docs:doc"},
map[string]interface{}{"subject": ""}, // empty subject filtered
"not-a-map", // ignored
},
}
got := ExtractRequiredScopes(detail)
want := []string{"docs:permission.member:create", "docs:doc"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("ExtractRequiredScopes mismatch: got %v, want %v", got, want)
}
}
func TestExtractRequiredScopes_NilOrMalformed(t *testing.T) {
cases := []interface{}{
nil,
"plain string",
map[string]interface{}{},
map[string]interface{}{"permission_violations": "not-a-list"},
map[string]interface{}{"permission_violations": []interface{}{}},
map[string]interface{}{"permission_violations": []interface{}{
map[string]interface{}{"subject": ""},
}},
}
for i, in := range cases {
if got := ExtractRequiredScopes(in); got != nil {
t.Errorf("case %d: expected nil, got %v", i, got)
}
}
}
func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) {
got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", "docs:permission.member:create")
if !strings.Contains(got, "open.feishu.cn") {
t.Errorf("feishu brand should use open.feishu.cn host, got %s", got)
}
if !strings.Contains(got, "clientID=cli_xxx") {
t.Errorf("missing app id in url: %s", got)
}
if !strings.Contains(got, "scopes=docs%3Apermission.member%3Acreate") {
t.Errorf("scope not URL-escaped: %s", got)
}
got = BuildConsoleScopeURL(core.BrandLark, "cli_yyy", "drive:drive")
if !strings.Contains(got, "open.larksuite.com") {
t.Errorf("lark brand should use open.larksuite.com host, got %s", got)
}
}
func TestBuildConsoleScopeURL_EmptyInput(t *testing.T) {
if got := BuildConsoleScopeURL(core.BrandFeishu, "", "docs:doc"); got != "" {
t.Errorf("empty appID should yield empty url, got %s", got)
}
if got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", ""); got != "" {
t.Errorf("empty scope should yield empty url, got %s", got)
}
}
func TestSelectRecommendedScopeFromStrings_FallsBackToFirst(t *testing.T) {
ensureFreshRegistry(t)
// Unknown scopes (not in priority table) → fallback to first
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "unknown:bar"}, "tenant")
if got != "unknown:foo" {
t.Errorf("expected fallback to first, got %s", got)
}
}
// When at least one scope is recognized by the priority table, the
// recommended scope wins over the fallback (first input).
func TestSelectRecommendedScopeFromStrings_PicksKnownScopeOverFallback(t *testing.T) {
ensureFreshRegistry(t)
// docs:permission.member:create is recommended (recommend=true) in
// scope_priorities.json. Putting an unknown scope first would otherwise
// win via the fallback path; this ensures the priority table is consulted
// before falling back.
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "docs:permission.member:create"}, "tenant")
if got != "docs:permission.member:create" {
t.Errorf("expected priority-table winner, got %s", got)
}
}
func TestSelectRecommendedScopeFromStrings_Empty(t *testing.T) {
if got := SelectRecommendedScopeFromStrings(nil, "tenant"); got != "" {
t.Errorf("nil slice should return empty, got %s", got)
}
if got := SelectRecommendedScopeFromStrings([]string{}, "tenant"); got != "" {
t.Errorf("empty slice should return empty, got %s", got)
}
}

View File

@@ -4,9 +4,12 @@
package common
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/validate"
)
@@ -81,6 +84,12 @@ func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), errMsg),
fmt.Sprintf("Auto-grant failed: %s. The app may lack the required scope or the resource restricts permission changes.", errMsg),
)
// Best-effort: when the underlying error is a structured permission
// ExitError (lark code 99991672/99991679), surface lark_code,
// required_scope and console_url so agents can guide users straight
// to the dev console. Overrides the generic hint with a more
// actionable one when console_url is available.
annotateGrantPermissionError(runtime, result, err)
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created, but auto-grant failed: %s. Retry later or grant permission manually.\n", errMsg)
return result
}
@@ -151,3 +160,54 @@ func compactPermissionGrantError(err error) string {
}
return strings.Join(strings.Fields(err.Error()), " ")
}
// annotateGrantPermissionError enriches a failed permission_grant result with
// structured fields (lark_code / required_scope / console_url) when the
// underlying error is a permission-class *output.ExitError. The CLI's main
// permission-error path (cmd/root.go::enrichPermissionError) handles the same
// case for top-level failures; this helper covers best-effort sub-calls whose
// error is folded into a result map instead of propagated as ExitError.
//
// When console_url is available, the existing generic hint is overridden with
// a more actionable one pointing at the developer console — that's the
// concrete next step a user can take.
func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]interface{}, err error) {
if runtime == nil || result == nil || err == nil {
return
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return
}
if exitErr.Detail.Type != "permission" {
return
}
if exitErr.Detail.Code != 0 {
result["lark_code"] = exitErr.Detail.Code
}
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
if recommended == "" {
return
}
result["required_scope"] = recommended
if runtime.Config == nil || runtime.Config.AppID == "" {
return
}
consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, recommended)
if consoleURL == "" {
return
}
result["console_url"] = consoleURL
// Override the generic hint: pointing at the dev console is more actionable
// than the generic "retry later" fallback set by buildPermissionGrantResult.
result["hint"] = fmt.Sprintf(
"App is missing the %q scope; enable it in the developer console (see console_url), then retry.",
recommended,
)
}

View File

@@ -5,12 +5,14 @@ package common
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
@@ -94,3 +96,216 @@ func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) {
t.Fatalf("hint = %#v, want string containing 'permission changes'", result["hint"])
}
}
// ── annotateGrantPermissionError unit tests ────────────────────────────────
func newAnnotateRuntime(brand core.LarkBrand, appID string) *RuntimeContext {
return &RuntimeContext{
Config: &core.CliConfig{
AppID: appID,
Brand: brand,
},
}
}
// permission_violations subjects must surface as required_scope, and the
// console_url must be brand-specific. The hint should be overridden to point
// at the developer console.
func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
result := map[string]interface{}{
"hint": "generic fallback hint",
}
err := output.ErrAPI(99991672, "Permission denied [99991672]", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
})
annotateGrantPermissionError(rt, result, err)
if got := result["lark_code"]; got != 99991672 {
t.Errorf("expected lark_code=99991672, got %v", got)
}
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
t.Errorf("required_scope mismatch: got %v", got)
}
consoleURL, _ := result["console_url"].(string)
if !strings.HasPrefix(consoleURL, "https://open.feishu.cn/page/scope-apply") {
t.Errorf("console_url should target open.feishu.cn, got %s", consoleURL)
}
if !strings.Contains(consoleURL, "clientID=cli_demo") {
t.Errorf("console_url missing clientID, got %s", consoleURL)
}
hint, _ := result["hint"].(string)
if !strings.Contains(hint, "console_url") {
t.Errorf("hint should reference console_url, got %s", hint)
}
if !strings.Contains(hint, "docs:permission.member:create") {
t.Errorf("hint should mention required scope, got %s", hint)
}
}
func TestAnnotateGrantPermissionError_LarkBrand(t *testing.T) {
rt := newAnnotateRuntime(core.BrandLark, "cli_demo")
result := map[string]interface{}{}
err := output.ErrAPI(99991679, "Permission denied [99991679]", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
})
annotateGrantPermissionError(rt, result, err)
if u, _ := result["console_url"].(string); !strings.Contains(u, "open.larksuite.com") {
t.Errorf("lark brand should yield larksuite host, got %s", u)
}
}
// Non-permission errors (network, validation, plain errors) must not be
// annotated — keep the existing generic hint untouched.
func TestAnnotateGrantPermissionError_NonPermissionErrorNoOp(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
cases := []error{
errors.New("plain error"),
output.ErrNetwork("connection reset"),
output.ErrValidation("bad request"),
// Non-permission API errors (e.g. 230001) — type is "api_error" not "permission"
output.ErrAPI(230001, "no permission", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:doc"},
},
}),
}
for i, e := range cases {
result := map[string]interface{}{
"hint": "untouched hint",
}
annotateGrantPermissionError(rt, result, e)
if _, ok := result["lark_code"]; ok {
t.Errorf("case %d: expected no lark_code, got %v", i, result["lark_code"])
}
if _, ok := result["console_url"]; ok {
t.Errorf("case %d: expected no console_url, got %v", i, result["console_url"])
}
if got, _ := result["hint"].(string); got != "untouched hint" {
t.Errorf("case %d: hint should be unchanged, got %s", i, got)
}
}
}
// permission_violations missing → only lark_code is annotated; no console_url
// and the existing hint stays as-is (caller's generic fallback wins).
func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
result := map[string]interface{}{
"hint": "untouched fallback",
}
err := output.ErrAPI(99991672, "Permission denied [99991672]", nil)
annotateGrantPermissionError(rt, result, err)
if got := result["lark_code"]; got != 99991672 {
t.Errorf("expected lark_code captured, got %v", got)
}
if _, ok := result["console_url"]; ok {
t.Errorf("console_url must not be set when violations are absent")
}
if got, _ := result["hint"].(string); got != "untouched fallback" {
t.Errorf("hint should remain fallback when no console_url, got %s", got)
}
}
// AppID empty → no console_url even when violations exist.
func TestAnnotateGrantPermissionError_EmptyAppID(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "")
result := map[string]interface{}{}
err := output.ErrAPI(99991672, "Permission denied", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:doc"},
},
})
annotateGrantPermissionError(rt, result, err)
if _, ok := result["console_url"]; ok {
t.Errorf("console_url must not be set when appID is empty")
}
if got, _ := result["required_scope"].(string); got != "docs:doc" {
t.Errorf("required_scope should still be set when appID is empty, got %s", got)
}
}
// Defensive: nil/empty arguments must be safe no-ops.
func TestAnnotateGrantPermissionError_NilArgsSafe(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
annotateGrantPermissionError(nil, map[string]interface{}{}, nil)
annotateGrantPermissionError(rt, nil, nil)
annotateGrantPermissionError(rt, map[string]interface{}{}, nil)
annotateGrantPermissionError(rt, map[string]interface{}{}, errors.New(""))
}
// Integration-style: end-to-end through AutoGrantCurrentUserDrivePermission
// with a mocked 99991672 response — verifies the annotated fields show up
// in the JSON result that callers downstream consume.
func TestAutoGrantStderrWarning_GrantFailed_AppScopeNotEnabled_Annotated(t *testing.T) {
config := &core.CliConfig{
AppID: "cli_app_demo",
AppSecret: "secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test_user",
}
f, _, _, reg := cmdutil.TestFactory(t, config)
// Stub the permission member create endpoint with a 99991672 response that
// includes permission_violations — what the platform returns when the app
// has not enabled the API scope.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
Body: map[string]interface{}{
"code": 99991672,
"msg": "App scope not enabled",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
},
},
})
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-3")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result")
}
if result["status"] != PermissionGrantFailed {
t.Fatalf("status = %v, want failed", result["status"])
}
if result["lark_code"] != 99991672 {
t.Errorf("lark_code = %v, want 99991672", result["lark_code"])
}
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
t.Errorf("required_scope = %v, want docs:permission.member:create", got)
}
consoleURL, _ := result["console_url"].(string)
if !strings.Contains(consoleURL, "open.feishu.cn/page/scope-apply") {
t.Errorf("console_url missing or wrong host: %s", consoleURL)
}
if !strings.Contains(consoleURL, "scopes=docs%3Apermission.member%3Acreate") {
t.Errorf("console_url missing escaped scope: %s", consoleURL)
}
hint, _ := result["hint"].(string)
if !strings.Contains(hint, "console_url") {
t.Errorf("hint should be overridden to mention console_url, got %s", hint)
}
}

View File

@@ -176,7 +176,11 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
if firstErrCode != 0 {
return nil, output.ErrAPI(firstErrCode, msg, "")
}
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
// No structured API code — the failure was transport, parse, panic, or
// cancellation. Suggest the actionable next step rather than shipping
// an empty hint that would leave the calling agent with nothing to do.
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg,
"retry the command; if it persists, narrow --queries to a single term to isolate the failing input")
}
return out, nil
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -18,6 +19,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -1133,6 +1135,33 @@ func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
}
}
// When all queries fail with no structured Lark API code (transport, parse,
// panic, ctx-canceled), the returned ExitError must carry an actionable
// hint so the calling agent has a next step to try instead of giving up.
func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "transport: connection refused"},
{Index: 1, Query: "bob", ErrMsg: "transport: timeout"},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil {
t.Fatalf("expected Detail, got nil")
}
if exitErr.Detail.Hint == "" {
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
}
if !strings.Contains(exitErr.Detail.Hint, "retry") {
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
}
}
// Codes from the first failure must propagate through output.ErrAPI so the
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// instead of 0, which would mean "success" in the Lark protocol.

View File

@@ -2331,15 +2331,15 @@ func validateSendTime(runtime *common.RuntimeContext) error {
return nil
}
if !runtime.Bool("confirm-send") {
return fmt.Errorf("--send-time requires --confirm-send to be set")
return output.ErrValidation("--send-time requires --confirm-send to be set")
}
ts, err := strconv.ParseInt(sendTime, 10, 64)
if err != nil {
return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
return output.ErrValidation("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
}
minTime := time.Now().Unix() + 5*60
if ts < minTime {
return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
return output.ErrValidation("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
}
return nil
}
@@ -2444,10 +2444,10 @@ func validateRecipientCount(to, cc, bcc string) error {
func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error {
if strings.TrimSpace(inlineFlag) != "" {
if plainText {
return fmt.Errorf("--inline is not supported with --plain-text (inline images require HTML body)")
return output.ErrValidation("--inline is not supported with --plain-text (inline images require HTML body)")
}
if body != "" && !bodyIsHTML(body) {
return fmt.Errorf("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
return output.ErrValidation("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
}
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
@@ -2529,7 +2529,7 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
return output.ErrValidation("--event-summary, --event-start, and --event-end must all be provided together")
}
if summary == "" {
return nil

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -241,7 +242,7 @@ func signatureCIDs(sig *signatureResult) []string {
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
return output.ErrValidation("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
}
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -463,7 +464,7 @@ func validateExpectedFlag(s string) error {
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
}
return nil
}

View File

@@ -38,7 +38,7 @@ const defaultTaskAttachmentResourceType = "task"
var UploadAttachmentTask = common.Shortcut{
Service: "task",
Command: "+upload-attachment",
Description: "upload a local file as an attachment to a task; use --resource-type=task_delivery when uploading to task agents",
Description: "upload a local file as an attachment to a task",
Risk: "write",
Scopes: []string{"task:attachment:write"},
AuthTypes: []string{"user", "bot"},

View File

@@ -5,8 +5,11 @@ package wiki
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -24,6 +27,16 @@ const (
wikiResolvedByMyLibrary = "my_library"
)
const (
// wikiNodeCreateMaxRetries is the maximum number of retry attempts after
// the initial request when the API returns lock contention (code 131009).
wikiNodeCreateMaxRetries = 2
// wikiNodeCreateRetryBaseDelay is the initial backoff delay for lock
// contention retries. Subsequent retries double the delay (250ms, 500ms).
wikiNodeCreateRetryBaseDelay = 250 * time.Millisecond
)
var wikiObjectTypes = []string{
"sheet",
"mindnote",
@@ -68,7 +81,7 @@ var WikiNodeCreate = common.Shortcut{
spec := readWikiNodeCreateSpec(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki node...\n")
execution, err := runWikiNodeCreate(ctx, wikiNodeCreateAPI{runtime: runtime}, runtime.As(), spec)
execution, err := runWikiNodeCreate(ctx, wikiNodeCreateAPI{runtime: runtime}, runtime.As(), spec, runtime.IO().ErrOut)
if err != nil {
return err
}
@@ -288,15 +301,37 @@ func needsMyLibraryLookup(spec wikiNodeCreateSpec) bool {
return spec.SpaceID == "" || spec.SpaceID == wikiMyLibrarySpaceID
}
func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec) (*wikiNodeCreateExecution, error) {
func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec, errOut io.Writer) (*wikiNodeCreateExecution, error) {
resolvedSpace, err := resolveWikiNodeCreateSpace(ctx, client, identity, spec)
if err != nil {
return nil, err
}
node, err := client.CreateNode(ctx, resolvedSpace.SpaceID, spec)
if err != nil {
return nil, err
var (
node *wikiNodeRecord
lastErr error
)
for attempt := 0; attempt <= wikiNodeCreateMaxRetries; attempt++ {
if attempt > 0 {
delay := wikiNodeCreateRetryBaseDelay << uint(attempt-1)
fmt.Fprintf(errOut, "Wiki node create encountered lock contention, retrying (attempt %d/%d) in %v...\n", attempt, wikiNodeCreateMaxRetries, delay)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
node, lastErr = client.CreateNode(ctx, resolvedSpace.SpaceID, spec)
if lastErr == nil {
break
}
if !isWikiNodeLockContention(lastErr) {
return nil, lastErr
}
}
if lastErr != nil {
return nil, wrapWikiNodeCreateRetryError(lastErr)
}
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
@@ -308,6 +343,50 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
}, nil
}
// isWikiNodeLockContention returns true if the error is a Lark API error with
// code 131009 (wiki node lock contention), which is retryable with backoff.
func isWikiNodeLockContention(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return false
}
return exitErr.Detail.Code == output.LarkErrWikiLockContention
}
// wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original
// API error. It builds the ExitError by hand (instead of using ErrWithHint) so
// the original Lark error code survives in the envelope.
func wrapWikiNodeCreateRetryError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
hint := fmt.Sprintf(
"wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent",
wikiNodeCreateMaxRetries,
)
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
hint = existing + "\n" + hint
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
Err: exitErr.Err,
Raw: exitErr.Raw,
}
}
// resolveWikiNodeCreateSpace applies the shortcut's precedence rules:
// explicit space ID wins, then parent-node inference, then my_library fallback.
func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec) (wikiResolvedSpace, error) {

View File

@@ -7,7 +7,9 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync/atomic"
"testing"
@@ -17,6 +19,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,6 +34,7 @@ type fakeWikiNodeCreateClient struct {
createNode *wikiNodeRecord
returnNilNode bool
createErr error
createErrs []error // consumed in order; takes precedence over createErr
getSpaceErr error
getNodeErr error
createInvoked []fakeWikiNodeCreateCall
@@ -63,6 +67,11 @@ func (fake *fakeWikiNodeCreateClient) CreateNode(ctx context.Context, spaceID st
SpaceID: spaceID,
Spec: spec,
})
if len(fake.createErrs) > 0 {
err := fake.createErrs[0]
fake.createErrs = fake.createErrs[1:]
return nil, err
}
if fake.createErr != nil {
return nil, fake.createErr
}
@@ -248,7 +257,7 @@ func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) {
ObjType: "docx",
Title: "Roadmap",
}
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec)
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, io.Discard)
if err != nil {
t.Fatalf("runWikiNodeCreate() error = %v", err)
}
@@ -280,7 +289,7 @@ func TestRunWikiNodeCreateRejectsNilCreatedNode(t *testing.T) {
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
})
}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "wiki node create returned no node") {
t.Fatalf("expected missing node error, got %v", err)
}
@@ -772,3 +781,237 @@ func TestWikiNodeURL(t *testing.T) {
})
}
}
func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createNode: &wikiNodeRecord{
SpaceID: "space_my_library",
NodeToken: "wik_created",
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
},
createErrs: []error{lockErr, lockErr}, // fail twice, then succeed
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err != nil {
t.Fatalf("runWikiNodeCreate() error = %v", err)
}
if len(client.createInvoked) != 3 {
t.Fatalf("create invoked %d times, want 3", len(client.createInvoked))
}
if execution.Node.NodeToken != "wik_created" {
t.Fatalf("node token = %q, want %q", execution.Node.NodeToken, "wik_created")
}
if !strings.Contains(stderr.String(), "lock contention") {
t.Fatalf("stderr = %q, want lock contention log", stderr.String())
}
if !strings.Contains(stderr.String(), "retrying (attempt 1/") {
t.Fatalf("stderr = %q, want attempt 1 log", stderr.String())
}
if !strings.Contains(stderr.String(), "retrying (attempt 2/") {
t.Fatalf("stderr = %q, want attempt 2 log", stderr.String())
}
}
func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createErrs: []error{lockErr, lockErr, lockErr}, // all 3 attempts fail
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
_, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err == nil {
t.Fatalf("expected error after retries exhausted")
}
if len(client.createInvoked) != 3 {
t.Fatalf("create invoked %d times, want 3", len(client.createInvoked))
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T: %v", err, err)
}
if exitErr.Detail.Code != output.LarkErrWikiLockContention {
t.Fatalf("error code = %d, want %d", exitErr.Detail.Code, output.LarkErrWikiLockContention)
}
if !strings.Contains(exitErr.Detail.Hint, "failed after 2 retries") {
t.Fatalf("hint = %q, want retry exhaustion message", exitErr.Detail.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "lock contention") {
t.Fatalf("hint = %q, want original classification hint preserved", exitErr.Detail.Hint)
}
}
func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
t.Parallel()
otherErr := output.ErrAPI(output.LarkErrRateLimit, "rate limit", nil) // rate limit, not lock contention
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createErrs: []error{otherErr},
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
_, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err == nil {
t.Fatalf("expected error")
}
if len(client.createInvoked) != 1 {
t.Fatalf("create invoked %d times, want 1 (no retry)", len(client.createInvoked))
}
if strings.Contains(stderr.String(), "retrying") {
t.Fatalf("stderr = %q, should not contain retry log for non-contention error", stderr.String())
}
}
func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createNode: &wikiNodeRecord{
SpaceID: "space_my_library",
NodeToken: "wik_created",
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
},
createErrs: []error{lockErr}, // fail once, then succeed
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err != nil {
t.Fatalf("runWikiNodeCreate() error = %v", err)
}
if len(client.createInvoked) != 2 {
t.Fatalf("create invoked %d times, want 2", len(client.createInvoked))
}
if execution.Node.NodeToken != "wik_created" {
t.Fatalf("node token = %q, want %q", execution.Node.NodeToken, "wik_created")
}
if !strings.Contains(stderr.String(), "retrying (attempt 1/") {
t.Fatalf("stderr = %q, want attempt 1 log", stderr.String())
}
if strings.Contains(stderr.String(), "retrying (attempt 2/") {
t.Fatalf("stderr = %q, should not contain attempt 2 log", stderr.String())
}
}
func TestRunWikiNodeCreateRetryContextCancelled(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createErrs: []error{lockErr, lockErr, lockErr}, // always fail
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
// Pre-cancel the context so the retry loop's select picks up
// ctx.Done() immediately during the first backoff wait.
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := runWikiNodeCreate(ctx, client, core.AsUser, spec, &stderr)
if err == nil {
t.Fatalf("expected error due to context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
// The initial attempt runs (context is checked only during backoff
// wait), but no retries should complete.
if len(client.createInvoked) != 1 {
t.Fatalf("create invoked %d times, want 1 (no retries after cancel)", len(client.createInvoked))
}
}
func TestRunWikiNodeCreateNoRetryOnSuccess(t *testing.T) {
t.Parallel()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createNode: &wikiNodeRecord{
SpaceID: "space_my_library",
NodeToken: "wik_created",
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
},
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err != nil {
t.Fatalf("runWikiNodeCreate() error = %v", err)
}
if len(client.createInvoked) != 1 {
t.Fatalf("create invoked %d times, want 1", len(client.createInvoked))
}
if execution.Node.NodeToken != "wik_created" {
t.Fatalf("node token = %q, want %q", execution.Node.NodeToken, "wik_created")
}
if strings.Contains(stderr.String(), "retrying") {
t.Fatalf("stderr = %q, should not contain retry log on success", stderr.String())
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the
@@ -57,14 +58,26 @@ var WikiNodeGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them", Required: true},
{Name: "obj-type", Desc: "obj_type when --token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
// --node-token is the canonical flag, matching sibling wiki commands
// (+node-delete / +node-copy / +move). --token is the original name
// and is kept as a hidden deprecated alias for backward compatibility;
// MarkDeprecated (registered in PostMount) prints a stderr warning
// when --token is used.
{Name: "node-token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them"},
{Name: "token", Desc: "DEPRECATED: use --node-token", Hidden: true},
{Name: "obj-type", Desc: "obj_type when --node-token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
},
Tips: []string{
"--token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
},
PostMount: func(cmd *cobra.Command) {
// cobra's MarkDeprecated prints "Flag --token has been deprecated, use --node-token instead"
// to stderr on use, and hides the flag from --help (matching the Hidden: true marker above).
_ = cmd.Flags().MarkDeprecated("token", "use --node-token instead")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiNodeGetSpec(runtime)
@@ -142,20 +155,45 @@ func (spec wikiNodeGetSpec) RequestParams() map[string]interface{} {
}
func readWikiNodeGetSpec(runtime *common.RuntimeContext) (wikiNodeGetSpec, error) {
return parseWikiNodeGetSpec(
rawToken, err := resolveWikiNodeGetRawToken(
runtime.Str("node-token"),
runtime.Str("token"),
)
if err != nil {
return wikiNodeGetSpec{}, err
}
return parseWikiNodeGetSpec(
rawToken,
runtime.Str("obj-type"),
runtime.Str("space-id"),
)
}
// resolveWikiNodeGetRawToken picks between the canonical --node-token and the
// deprecated --token alias. Both empty is fine (parseWikiNodeGetSpec will
// surface the required-flag error). Both set with different values is rejected
// upfront so callers fix the obvious bug rather than silently picking one.
func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
canonical := strings.TrimSpace(nodeToken)
legacy := strings.TrimSpace(legacyToken)
switch {
case canonical != "" && legacy != "" && canonical != legacy:
return "", output.ErrValidation(
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)")
case canonical != "":
return nodeToken, nil
default:
return legacyToken, nil
}
}
// parseWikiNodeGetSpec normalizes the raw flag values: extracts a token from a
// URL when needed, picks the obj_type (URL path > explicit flag > none for
// node_tokens), and validates the token shape.
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--token is required")
return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required")
}
spec := wikiNodeGetSpec{
@@ -166,12 +204,12 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--token URL is malformed: %q", tokenInput)
return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeGetSpec{}, output.ErrValidation(
"unsupported --token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
}
@@ -192,7 +230,7 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeGetSpec{}, output.ErrValidation(
"--token must be a raw token or a full URL; partial paths are not accepted: %q",
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
} else {
@@ -223,7 +261,7 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
}
}
if err := validateOptionalResourceName(spec.Token, "--token"); err != nil {
if err := validateOptionalResourceName(spec.Token, "--node-token"); err != nil {
return wikiNodeGetSpec{}, err
}
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {

View File

@@ -4,6 +4,7 @@
package wiki
import (
"bytes"
"encoding/json"
"net/http"
"reflect"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
@@ -98,7 +100,7 @@ func TestParseWikiNodeGetSpecRejectsUnsupportedURLPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("https://feishu.cn/im/chat/oc_123", "", "")
if err == nil || !strings.Contains(err.Error(), "unsupported --token URL path") {
if err == nil || !strings.Contains(err.Error(), "unsupported --node-token URL path") {
t.Fatalf("expected unsupported URL path error, got %v", err)
}
}
@@ -115,11 +117,61 @@ func TestParseWikiNodeGetSpecRejectsPartialPath(t *testing.T) {
func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) {
t.Parallel()
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--token is required") {
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--node-token is required") {
t.Fatalf("expected required-token error, got %v", err)
}
}
func TestResolveWikiNodeGetRawTokenPrefersNodeToken(t *testing.T) {
t.Parallel()
got, err := resolveWikiNodeGetRawToken("wikcnNEW", "")
if err != nil || got != "wikcnNEW" {
t.Fatalf("resolve(node-token only) = (%q, %v), want (wikcnNEW, nil)", got, err)
}
}
func TestResolveWikiNodeGetRawTokenAcceptsLegacyToken(t *testing.T) {
t.Parallel()
got, err := resolveWikiNodeGetRawToken("", "wikcnLEGACY")
if err != nil || got != "wikcnLEGACY" {
t.Fatalf("resolve(legacy only) = (%q, %v), want (wikcnLEGACY, nil)", got, err)
}
}
func TestResolveWikiNodeGetRawTokenAcceptsBothWhenEqual(t *testing.T) {
t.Parallel()
// Same value on both flags is harmless (e.g. a script doubled the input
// while migrating to --node-token) — prefer the canonical one and don't
// surface a conflict error.
got, err := resolveWikiNodeGetRawToken("wikcnSAME", "wikcnSAME")
if err != nil || got != "wikcnSAME" {
t.Fatalf("resolve(both same) = (%q, %v), want (wikcnSAME, nil)", got, err)
}
}
func TestResolveWikiNodeGetRawTokenRejectsConflict(t *testing.T) {
t.Parallel()
_, err := resolveWikiNodeGetRawToken("wikcnNEW", "wikcnOLD")
if err == nil || !strings.Contains(err.Error(), "both set with different values") {
t.Fatalf("expected conflict error, got %v", err)
}
}
func TestResolveWikiNodeGetRawTokenEmptyDefersToParser(t *testing.T) {
t.Parallel()
// Both empty is not an error here — the caller (parseWikiNodeGetSpec) is
// where the required-flag check lives and produces the user-facing message.
got, err := resolveWikiNodeGetRawToken("", "")
if err != nil || got != "" {
t.Fatalf("resolve(empty) = (%q, %v), want ('', nil)", got, err)
}
}
func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
t.Parallel()
@@ -204,7 +256,7 @@ func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "https://feishu.cn/docx/docxXYZ",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--as", "bot",
}, factory, stdout)
if err != nil {
@@ -245,6 +297,150 @@ func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
}
}
func TestWikiNodeGetMountedAcceptsNodeTokenFlag(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"node_type": "origin",
"title": "Via Node-Token",
},
},
"msg": "success",
},
}
var capturedQuery string
stub.OnMatch = func(req *http.Request) {
capturedQuery = req.URL.RawQuery
}
reg.Register(stub)
// Mount inline (rather than using mountAndRunWiki) so we can redirect the
// subcommand's pflag output and assert that no deprecation warning leaks
// when the canonical --node-token is used. The deprecation message comes
// from pflag, not cobra, so SetErr on the cobra root is NOT enough — pflag
// writes to FlagSet.Output(), which we redirect via Flags().SetOutput.
var flagOut bytes.Buffer
parent := mountWikiNodeGetWithFlagOut(t, factory, &flagOut)
parent.SetArgs([]string{
"+node-get",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--as", "bot",
})
stdout.Reset()
if err := parent.Execute(); err != nil {
t.Fatalf("parent.Execute() error = %v", err)
}
if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["title"] != "Via Node-Token" {
t.Fatalf("title = %#v, want Via Node-Token", data["title"])
}
if got := flagOut.String(); strings.Contains(got, "deprecated") {
t.Fatalf("pflag output unexpectedly contains deprecation warning when using --node-token: %q", got)
}
}
// mountWikiNodeGetWithFlagOut mounts +node-get on a fresh parent and redirects
// the subcommand's pflag output to w so tests can capture cobra/pflag-level
// deprecation messages (which bypass the runtime IO stderr exposed by
// TestFactory).
func mountWikiNodeGetWithFlagOut(t *testing.T, factory *cmdutil.Factory, w *bytes.Buffer) *cobra.Command {
t.Helper()
parent := &cobra.Command{Use: "wiki"}
WikiNodeGet.Mount(parent, factory)
parent.SilenceErrors = true
parent.SilenceUsage = true
parent.SetErr(w)
for _, child := range parent.Commands() {
if child.Use == WikiNodeGet.Command {
child.Flags().SetOutput(w)
return parent
}
}
t.Fatalf("mountWikiNodeGetWithFlagOut: subcommand %q not registered on parent", WikiNodeGet.Command)
return nil
}
func TestWikiNodeGetMountedLegacyTokenFlagWarnsButWorks(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"node_type": "origin",
"title": "Legacy Token Path",
},
},
"msg": "success",
},
})
var flagOut bytes.Buffer
parent := mountWikiNodeGetWithFlagOut(t, factory, &flagOut)
parent.SetArgs([]string{
"+node-get",
"--token", "wikcnABC",
"--as", "bot",
})
stdout.Reset()
if err := parent.Execute(); err != nil {
t.Fatalf("parent.Execute() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["title"] != "Legacy Token Path" {
t.Fatalf("title = %#v, want Legacy Token Path", data["title"])
}
// pflag MarkDeprecated prints "Flag --token has been deprecated, use --node-token instead".
got := flagOut.String()
if !strings.Contains(got, "deprecated") || !strings.Contains(got, "--node-token") {
t.Fatalf("pflag output = %q, want a deprecation warning pointing to --node-token", got)
}
}
func TestWikiNodeGetMountedRejectsConflictingTokenFlags(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
// reg is unused: conflict is caught in Validate before any HTTP call.
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--node-token", "wikcnNEW",
"--token", "wikcnOLD",
"--as", "bot",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "both set with different values") {
t.Fatalf("expected conflict error, got %v", err)
}
}
func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -272,7 +468,7 @@ func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "wikcnABC",
"--node-token", "wikcnABC",
"--as", "bot",
}, factory, stdout)
if err != nil {
@@ -311,7 +507,7 @@ func TestWikiNodeGetRejectsSpaceIDMismatch(t *testing.T) {
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "wikcnABC",
"--node-token", "wikcnABC",
"--space-id", "space_expected",
"--as", "bot",
}, factory, stdout)

View File

@@ -1,6 +1,6 @@
---
name: lark-apps
description: "把本地 HTML 文件或目录部署到飞书妙搭Miaoda生成一个公网可访问的应用及其链接URL。当用户要创建 HTML 或要把 HTML、静态网站或 Web demo 发布成公网可访问的链接 / 可分享链接、设置应用共享范围,或提到妙搭 / Miaoda 时使用。凡产出可独立访问的 HTML 产物都属本 skill 的潜在归宿,是否真要部署由 skill 内部协议判断。不用于:上传普通文件到云空间(用 lark-drive、编辑飞书云文档内容用 lark-doc、创建飞书原生幻灯片 / 演示文稿(用 lark-slides。"
description: "把本地 HTML 文件或目录部署到飞书妙搭Miaoda生成一个公网可访问的应用及其链接URL。当用户要创建 HTML 或要把 HTML、静态网站或 Web demo 发布成公网可访问的链接 / 可分享链接、设置应用共享范围,或提到妙搭 / Miaoda 时使用。凡产出可独立访问的 HTML 产物都属本 skill 的潜在归宿,是否真要部署由 skill 内部协议判断。不用于:上传普通文件到云空间/云盘/云存储(用 lark-drive、编辑飞书云文档内容用 lark-doc、创建飞书原生幻灯片 / 演示文稿(用 lark-slides。"
metadata:
requires:
bins: ["lark-cli"]

View File

@@ -40,7 +40,7 @@ metadata:
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令。
3. 如果输入是 Wiki 链接或 Wiki token并且用户想读取/操作其中的 Base先执行 `lark-cli wiki +node-get --token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。
3. 如果输入是 Wiki 链接或 Wiki token并且用户想读取/操作其中的 Base先执行 `lark-cli wiki +node-get --node-token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。(旧的 `--token` flag 仍可用,但已 deprecated会在 stderr 打印迁移提示。)
4. 定位到命令后,先读该命令对应的 reference再执行命令。
5. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
6. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`
@@ -266,7 +266,7 @@ metadata:
Wiki Base fast path:
```bash
BASE_TOKEN="$(lark-cli wiki +node-get --as user --token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
BASE_TOKEN="$(lark-cli wiki +node-get --as user --node-token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
```
| `lark-cli wiki +node-get` 返回的 `data.obj_type` | 后续路线 | 说明 |
@@ -352,7 +352,7 @@ lark-cli auth login --domain base
| `1254066` | 人员字段错误 | `[{ "id": "ou_xxx" }]` |
| `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) |
| `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --node-token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
| `ignored_fields` / `READONLY` | 只读字段被当成可写字段常见于系统字段、formula、lookup | 移除只读字段,只写存储字段;计算结果交给 formula / lookup / 系统字段自动产出 |

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间:管理云空间中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable。当用户需要上传或下载文件、整理云空间目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件或要把本地文件导入成新版文档、电子表格、多维表格/Base 时使用。"
description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable。当用户需要上传或下载文件、整理云空间(云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。"
metadata:
requires:
bins: ["lark-cli"]
@@ -12,18 +12,20 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
> **术语说明:** 飞书云空间也常被称为"云盘"或"云存储",三者指的是同一个产品,是飞书官方的云端文件存储与管理中心。
> **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base``lark-base` 只负责导入完成后的表内操作。
## 快速决策
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
@@ -120,7 +122,7 @@ Wiki Space (知识空间)
└── obj_type: file/slides/mindnote
└── obj_token (真实文档 token)
Drive Folder (云空间文件夹)
Drive Folder (云空间/云盘/云存储文件夹)
└── File (文件/文档)
└── file_token (直接使用)
```

View File

@@ -178,5 +178,5 @@ lark-cli drive +add-comment \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -1,8 +1,8 @@
# drive +create-folder创建云空间文件夹
# drive +create-folder创建云空间/云盘/云存储文件夹)
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
在飞书云空间中创建一个新文件夹。该 shortcut 对原生 `drive files create_folder` 做了一层更适合日常使用的封装:`--folder-token` 可省略,此时会在调用者根目录创建;如果使用 `--as bot`,创建成功后 CLI 会尝试把新文件夹的可管理权限自动授予当前 CLI 用户。
在飞书云空间(云盘/云存储)中创建一个新文件夹。该 shortcut 对原生 `drive files create_folder` 做了一层更适合日常使用的封装:`--folder-token` 可省略,此时会在调用者根目录创建;如果使用 `--as bot`,创建成功后 CLI 会尝试把新文件夹的可管理权限自动授予当前 CLI 用户。
## 命令
@@ -60,7 +60,7 @@ lark-cli drive +create-folder \
## 推荐场景
- 用户说“在云空间新建一个文件夹 / 目录”时,优先使用 `drive +create-folder`
- 用户说“在云空间(云盘/云存储)新建一个文件夹 / 目录”时,优先使用 `drive +create-folder`
- 用户给了父文件夹链接或 token需要在其下继续分层建目录时`--folder-token`
- 如果后续还要上传文件、移动文件、建子目录,优先复用返回值里的 `folder_token`
@@ -69,5 +69,5 @@ lark-cli drive +create-folder \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -48,7 +48,7 @@ lark-cli drive +create-shortcut \
- CLI 层会把 `--file-token``--type` 组装为底层 API 所需的 `refer_entity`
- `--file-token` 必须是 Drive 文件 token不要直接传 wiki 节点 token
- 如果来源是 `/wiki/...` 链接,必须先按 [`lark-drive`](../SKILL.md) 中的 wiki 解析流程拿到真实 `obj_token`,再创建快捷方式
- 目标位置必须是云空间文件夹;这个 shortcut 不是“复制文件内容”,而是“在另一个文件夹里挂一个引用入口”
- 目标位置必须是云空间(云盘/云存储)文件夹;这个 shortcut 不是“复制文件内容”,而是“在另一个文件夹里挂一个引用入口”
## 类型说明
@@ -99,5 +99,5 @@ lark-cli drive +create-shortcut \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
删除云空间内的文件或文件夹。删除后资源会进入回收站。
删除云空间(云盘/云存储)内的文件或文件夹。删除后资源会进入回收站。
> [!CAUTION]
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
@@ -63,7 +63,7 @@ lark-cli drive +task_result \
## 限制
- 该 shortcut 仅支持云空间文件或文件夹,不支持 wiki 文档
- 该 shortcut 仅支持云空间(云盘/云存储)文件或文件夹,不支持 wiki 文档
- 该接口不支持并发调用
- 调用频率上限为 5 QPS 且 10000 次/天
@@ -75,5 +75,5 @@ lark-cli drive +task_result \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
从飞书云空间下载文件到本地。
从飞书云空间(云盘/云存储)下载文件到本地。
## 命令
@@ -27,5 +27,5 @@ https://xxx.feishu.cn/drive/file/boxbc_xxx
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -46,5 +46,5 @@ lark-cli drive +export-download \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -131,5 +131,5 @@ lark-cli drive +export-download \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -56,7 +56,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|------|------|------|
| `--file` | 是 | 本地文件路径,根据文件后缀名自动推断 `file_extension`;文件需满足对应格式的导入大小限制,超过 20MB 且仍在允许范围内时会自动切换分片上传 |
| `--type` | 是 | 导入目标云文档格式。可选值:`docx` (新版文档)、`sheet` (电子表格)、`bitable` (多维表格) |
| `--folder-token` | 否 | 目标文件夹 token不传则请求中的 `point.mount_key` 为空字符串Import API 会将其解释为导入到云空间根目录 |
| `--folder-token` | 否 | 目标文件夹 token不传则请求中的 `point.mount_key` 为空字符串Import API 会将其解释为导入到云空间(云盘/云存储)根目录 |
| `--name` | 否 | 导入后的在线云文档名称,不传默认使用本地文件名去掉扩展名后的结果 |
| `--target-token` | 否 | 已有的多维表格 token将数据导入到该多维表格中**仅支持 `--type bitable`**);传入后数据会挂载到目标多维表格而非新建一个 |
@@ -155,5 +155,5 @@ lark-cli drive +task_result --scenario import --ticket <TICKET>
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
将文件或文件夹移动到用户云空间的其他位置。
将文件或文件夹移动到用户云空间(云盘/云存储)的其他位置。
## 与 `wiki +move` 的区别
@@ -116,5 +116,5 @@ lark-cli drive +task_result \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
把飞书云空间的某个文件夹**单向、文件级**镜像到本地目录Drive → 本地)。命令递归列出 `--folder-token` 下所有 `type=file` 的文件,逐一下载到 `--local-dir` 对应的相对路径,子文件夹自动复刻为本地目录。
把飞书云空间(云盘/云存储)的某个文件夹**单向、文件级**镜像到本地目录Drive → 本地)。命令递归列出 `--folder-token` 下所有 `type=file` 的文件,逐一下载到 `--local-dir` 对应的相对路径,子文件夹自动复刻为本地目录。
> ⚠️ **不是 directory-level mirror**`--delete-local` 只删除本地"多余"的常规文件,不删除空目录。如果云端把整个子文件夹删了,对应的本地子目录会留空(里面的文件被清掉,目录本身保留);想精确同步目录结构请自己 `rmdir` 处理空壳。
@@ -131,7 +131,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
## 参考
- [lark-drive](../SKILL.md) —— 云空间全部命令
- [lark-drive](../SKILL.md) —— 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
- [lark-drive-status](lark-drive-status.md) —— 下载前先看差异
- [lark-drive-download](lark-drive-download.md) —— 单文件按需拉取

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
把本地目录**单向、文件级**镜像到飞书云空间的某个文件夹(本地 → Drive。命令递归列出 `--folder-token` 下的远端清单,遍历 `--local-dir` 的所有常规文件,按相对路径在 Drive 上新建、覆盖或跳过;可选地(`--delete-remote --yes`)删除云端"本地没有"的 `type=file`
把本地目录**单向、文件级**镜像到飞书云空间(云盘/云存储)的某个文件夹(本地 → Drive。命令递归列出 `--folder-token` 下的远端清单,遍历 `--local-dir` 的所有常规文件,按相对路径在 Drive 上新建、覆盖或跳过;可选地(`--delete-remote --yes`)删除云端"本地没有"的 `type=file`
> **"文件级镜像"≠"目录镜像"。** 命令只在文件维度收敛差异:本地多了文件就上传,本地少了文件且开了 `--delete-remote --yes` 就删远端文件。**远端只有的空目录、本地已删除的目录**都不会被收敛,云端目录树的多余结构不会被清理。如果需要"目录也要保持完全一致",得自行先 `+status` 找差异、再手动处理多余目录。
@@ -155,7 +155,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
## 参考
- [lark-drive](../SKILL.md) —— 云空间全部命令
- [lark-drive](../SKILL.md) —— 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
- [lark-drive-status](lark-drive-status.md) —— 上传前先看差异(避免全量回写)
- [lark-drive-pull](lark-drive-pull.md) —— Drive → 本地的对称命令

View File

@@ -109,5 +109,5 @@ Music, Typing, Pepper, CheckMark, CrossMark
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -1,9 +1,9 @@
# drive +search云空间搜索扁平 flag面向自然语言场景
# drive +search云空间/云盘/云存储搜索:扁平 flag面向自然语言场景
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间对象。
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间(云盘/云存储)对象。
核心特性:
@@ -12,7 +12,7 @@
- 自动处理 `my_edit_time` / `my_comment_time` 的小时级聚合(服务端存储粒度):亚小时输入会向整点 snap并在 stderr 打出提示
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`,不必再先去查 contact注意 `creator_ids` 服务端按 **owner / 文档归属人** 语义匹配,不是“最初创建人”,详见下文「身份维度」)
> **资源发现入口统一**`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill如 `lark-sheets`)做对象内部操作。
> **资源发现入口统一**`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间(云盘/云存储)对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill如 `lark-sheets`)做对象内部操作。
## 命令
@@ -216,7 +216,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
| 操作 | 所需 scope |
|---|---|
| 搜索云空间对象(文档 / Wiki / 表格等资源发现) | `search:docs:read` |
| 搜索云空间(云盘/云存储)对象(文档 / Wiki / 表格等资源发现) | `search:docs:read` |
## 常见错误

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
**精确 SHA-256**(默认)或 **快速 modified_time**`--quick`)比较本地目录与飞书云空间文件夹,输出四类差异:
**精确 SHA-256**(默认)或 **快速 modified_time**`--quick`)比较本地目录与飞书云空间(云盘/云存储)文件夹,输出四类差异:
| 字段 | 含义 |
|------|------|
@@ -132,6 +132,6 @@ lark-cli drive +status \
## 参考
- [lark-drive](../SKILL.md) —— 云空间全部命令
- [lark-drive](../SKILL.md) —— 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
- [lark-drive-upload](lark-drive-upload.md) / [lark-drive-download](lark-drive-download.md) —— 把 +status 输出接到推/拉动作上

View File

@@ -298,5 +298,5 @@ lark-cli drive +export-download --file-token <EXPORTED_FILE_TOKEN>
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
上传本地文件到飞书云空间。目标位置可以是 Drive 文件夹,也可以是 wiki 节点。
上传本地文件到飞书云空间(云盘/云存储)。目标位置可以是 Drive 文件夹,也可以是 wiki 节点。
## 快速决策
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
@@ -97,5 +97,5 @@ Shortcut 参数:
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -34,5 +34,5 @@ lark-cli drive +version-delete \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -67,5 +67,5 @@ lark-cli drive +version-get \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -69,5 +69,5 @@ lark-cli drive +version-history \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -31,5 +31,5 @@ lark-cli drive +version-revert \
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -21,7 +21,7 @@ metadata:
- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite`
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
- 用户要把本地 Markdown **导入成在线新版文档docx**,不要用本 skill改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill切到 [`lark-drive`](../lark-drive/SKILL.md)
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill切到 [`lark-drive`](../lark-drive/SKILL.md)
## 核心边界

View File

@@ -66,11 +66,11 @@ lark-cli vc +notes --minute-tokens <minute_token>
1. 当用户需要通过上传本地音视频文件来生成妙记时使用。
2. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
3. **处理流程**
- **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间并获取 `file_token`
- **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间(云盘/云存储)并获取 `file_token`
- **生成妙记**:获取到 `file_token` 后,调用 [`lark-cli minutes +upload`](references/lark-minutes-upload.md) 将文件转换为妙记并获取 `minute_url` 链接。
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli vc +notes --minute-tokens`](../lark-vc/references/lark-vc-notes.md) 获取对应产物。
> **注意**:必须先获取飞书云空间的 `file_token` 才能进行转换。
> **注意**:必须先获取飞书云空间(云盘/云存储)的 `file_token` 才能进行转换。
>
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。

View File

@@ -17,8 +17,8 @@
当用户要求将音视频文件转换为妙记,或进一步要纪要/逐字稿/文字稿/撰写文字时,必须按照以下步骤执行:
1. **上传文件至云空间获取 file_token**
- 使用 `lark-cli drive +upload` 命令上传本地文件到云空间Drive
1. **上传文件至云空间(云盘/云存储)获取 file_token**
- 使用 `lark-cli drive +upload` 命令上传本地文件到云空间/云盘/云存储Drive
```bash
lark-cli drive +upload --file <path/to/media/file>
```
@@ -44,7 +44,7 @@
## 命令示例
```bash
# 通过已上传到云空间的 file_token 生成妙记
# 通过已上传到云空间(云盘/云存储)的 file_token 生成妙记
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
# 通过 minute_token 继续获取纪要 / 逐字稿 / 文字稿 / AI 产物
@@ -55,7 +55,7 @@ lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token <token>` | 是 | 已经上传到飞书云空间的音视频文件的 file_token |
| `--file-token <token>` | 是 | 已经上传到飞书云空间(云盘/云存储)的音视频文件的 file_token |
## 支持的格式与限制
@@ -72,13 +72,13 @@ lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
### 1. 必须提供 file_token
本接口不直接处理本地文件的上传,必须先使用 `drive +upload` 将文件上传到云空间获取 `file_token`,然后再调用本接口。
本接口不直接处理本地文件的上传,必须先使用 `drive +upload` 将文件上传到云空间(云盘/云存储)获取 `file_token`,然后再调用本接口。
### 2. 先上传,再生成妙记
推荐流程如下:
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间(云盘/云存储)
2. 从返回结果中取出 `file_token`
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli vc +notes --minute-tokens <minute_token>`
@@ -100,5 +100,5 @@ lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间
- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间(云盘/云存储)
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -14,7 +14,7 @@ description: "Use when first setting up lark-cli, running auth login, switching
当你帮用户初始化配置时使用background方式使用下面的命令发起配置应用流程启动后读取输出从中提取授权链接并发给用户。
**URL 转发规则**:当命令输出 `verification_url``verification_uri_complete``console_url` 等 URL 字段时,必须将 URL exactly as returned by the CLI 转发给用户,并把它视为不可修改的 opaque string不要做 URL encode/decode不要补 `%20`空格或标点,不要重新拼接 query,不要改写成 Markdown link text,建议用包含原始 URL 的代码块单独输出。
**URL 转发规则**:当命令输出 `verification_url``verification_uri_complete``console_url` 等 URL 字段时**必须生成二维码**:你必须调用 `lark-cli auth qrcode` 将 URL 转为二维码并展示给用户,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**URL 输出规则**:将 URL 视为不可修改的 opaque string不要做任何修改(包括 URL 编码/解码、添加空格或标点重新拼接 query,建议用包含原始 URL 的代码块单独输出,二维码和链接请一起展示给用户
```bash
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
@@ -29,14 +29,14 @@ lark-cli config init --new
| 身份 | 标识 | 获取方式 | 适用场景 |
|------|------|---------|---------|
| user 用户身份 | `--as user` | `lark-cli auth login` 等 | 访问用户自己的资源(日历、云空间等) |
| user 用户身份 | `--as user` | `lark-cli auth login` 等 | 访问用户自己的资源(日历、云空间/云盘/云存储等) |
| bot 应用身份 | `--as bot` | 自动,只需 appId + appSecret | 应用级操作,访问bot自己的资源 |
### 身份选择原则
输出的 `[identity: bot/user]` 代表当前身份。bot 与 user 表现差异很大,需确认身份符合目标需求:
- **Bot 看不到用户资源**:无法访问用户的日历、云空间文档、邮箱等个人资源。例如 `--as bot` 查日程返回 bot 自己的(空)日历
- **Bot 看不到用户资源**:无法访问用户的日历、云空间(云盘/云存储)文档、邮箱等个人资源。例如 `--as bot` 查日程返回 bot 自己的(空)日历
- **Bot 无法代表用户操作**:发消息以应用名义发送,创建文档归属 bot
- **Bot 权限**:只需在飞书开发者后台开通 scope无需 `auth login`
- **User 权限**:后台开通 scope + 用户通过 `auth login` 授权,两层都要满足

View File

@@ -1,7 +1,7 @@
---
name: lark-sheets
version: 1.2.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。"
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。"
metadata:
requires:
bins: ["lark-cli"]
@@ -95,7 +95,7 @@ Wiki Space (知识空间)
└── obj_type: file/slides/mindnote
└── obj_token (真实文档 token)
Drive Folder (云空间文件夹)
Drive Folder (云空间/云盘/云存储文件夹)
└── File (文件/文档)
└── file_token (直接使用)
```

View File

@@ -121,7 +121,7 @@ lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \
对应命令:`lark-cli sheets +find`
只在一个已知 spreadsheet 内查找单元格内容,不是云空间搜索。
只在一个已知 spreadsheet 内查找单元格内容,不是云空间(云盘/云存储)搜索。
```bash
lark-cli sheets +find --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \

View File

@@ -33,26 +33,26 @@ metadata:
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识GUID不是客户端展示的任务编号例如 `t104121` / `suite_entity_num`)。
> 对于 Feishu 的任务 applink例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
## Shortcuts
- [`+create`](./references/lark-task-create.md) — Create a task
- [`+update`](./references/lark-task-update.md) — Update a task
- [`+comment`](./references/lark-task-comment.md) — Add a comment to a task
- [`+complete`](./references/lark-task-complete.md) — Complete a task
- [`+reopen`](./references/lark-task-reopen.md) — Reopen a task
- [`+assign`](./references/lark-task-assign.md) — Assign or remove members from a task
- [`+followers`](./references/lark-task-followers.md) — Manage task followers
- [`+reminder`](./references/lark-task-reminder.md) — Manage task reminders
- [`+get-my-tasks`](./references/lark-task-get-my-tasks.md) — List tasks assigned to me
- [`+get-related-tasks`](./references/lark-task-get-related-tasks.md) List tasks related to me
- [`+search`](./references/lark-task-search.md) — Search tasks
- [`+subscribe-event`](./references/lark-task-subscribe-event.md) — Subscribe to task events
- [`+set-ancestor`](./references/lark-task-set-ancestor.md) — Set or clear a task ancestor
- [`+tasklist-create`](./references/lark-task-tasklist-create.md) — Create a tasklist and batch add tasks
- [`+tasklist-search`](./references/lark-task-tasklist-search.md) — Search tasklists
- [`+tasklist-task-add`](./references/lark-task-tasklist-task-add.md) — Add existing tasks to a tasklist
- [`+tasklist-members`](./references/lark-task-tasklist-members.md) — Manage tasklist members
- [`+upload-attachment`](./references/lark-task-upload-attachment.md) — Upload a file as a task attachment
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-task-create.md) | create a task |
| [`+update`](references/lark-task-update.md) | update task attributes |
| [`+set-ancestor`](references/lark-task-set-ancestor.md) | set or clear a task ancestor |
| [`+comment`](references/lark-task-comment.md) | add a comment to a task |
| [`+complete`](references/lark-task-complete.md) | mark a task as complete |
| [`+reopen`](references/lark-task-reopen.md) | reopen a completed task |
| [`+assign`](references/lark-task-assign.md) | assign or remove task members |
| [`+followers`](references/lark-task-followers.md) | manage task followers |
| [`+reminder`](references/lark-task-reminder.md) | manage task reminders |
| [`+get-my-tasks`](references/lark-task-get-my-tasks.md) | List tasks assigned to me |
| [`+get-related-tasks`](references/lark-task-get-related-tasks.md) | list tasks related to me |
| [`+search`](references/lark-task-search.md) | search tasks |
| [`+subscribe-event`](references/lark-task-subscribe-event.md) | subscribe to task events |
| [`+upload-attachment`](references/lark-task-upload-attachment.md) | upload a local file as an attachment to a task |
| [`+tasklist-create`](references/lark-task-tasklist-create.md) | create a tasklist and optionally add tasks |
| [`+tasklist-search`](references/lark-task-tasklist-search.md) | search tasklists |
| [`+tasklist-task-add`](references/lark-task-tasklist-task-add.md) | add tasks to a tasklist |
| [`+tasklist-members`](references/lark-task-tasklist-members.md) | manage tasklist members |
## API Resources
@@ -162,4 +162,3 @@ lark-cli task <resource> <method> [flags] # 调用 API
| `agent.update_agent_profile` | `task:task:write` |
| `agent.register_agent` | `task:task:write` |
| `agent_task_step_info.append_task_steps` | `task:task:write` |
| `+upload-attachment` | `task:attachment:write` |

View File

@@ -52,7 +52,7 @@ metadata:
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move``wiki +node-create` 或其他 Wiki 写操作
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
- 如果用户明确说的是 Drive 文件夹、云空间(云盘/云存储)根目录、`我的空间`,才进入 Drive 域处理
## Shortcuts推荐优先使用

View File

@@ -15,12 +15,12 @@
- `drive +move` 的目标是 **Drive 文件夹**,使用 `--folder-token`
- 如果源对象已经是 Wiki 节点,必须使用 `wiki +move`,而不是 `drive +move`
- 如果源对象还是 Drive 文档,但用户要“迁入知识库”“挂到某个 Wiki 页面下”,也应使用 `wiki +move`
- 如果用户只是想整理云空间文件夹,把文件/文件夹挪到另一个 Drive 文件夹,应使用 `drive +move`
- 如果用户只是想整理云空间(云盘/云存储)文件夹,把文件/文件夹挪到另一个 Drive 文件夹,应使用 `drive +move`
## 口语目标识别
- 当用户说“移动到某个知识库”“挂到某个页面下”“迁入 Wiki”时**Wiki 目标** 处理,优先使用 `wiki +move`
- 当用户说“移动到某个文件夹”“移动到云空间根目录”时,按 **Drive 文件夹目标** 处理,优先使用 `drive +move`
- 当用户说“移动到某个文件夹”“移动到云空间(云盘/云存储)根目录”时,按 **Drive 文件夹目标** 处理,优先使用 `drive +move`
- 当用户说“移动到我的文档库”“移动到我的知识库”“放到个人知识库”时,应先按 **Wiki 个人知识库目标** 理解,而不是直接退化成 `drive +move`
- 遇到“我的文档库”这类表述时,可以把它理解成:先用 `my_library` 去查询用户个人知识库,再拿到真实 `space_id`
- 推荐做法是先执行 `lark-cli wiki spaces get --params '{"space_id":"my_library"}'`,取回真实知识库 `space_id`,再把这个 `space_id` 用到 `wiki +move`

View File

@@ -6,7 +6,7 @@ Get a wiki node's details by `node_token`, `obj_token`, or a Lark URL. Use this
```bash
lark-cli wiki +node-get \
--token <node_token | obj_token | Lark URL> \
--node-token <node_token | obj_token | Lark URL> \
[--obj-type <doc|docx|sheet|bitable|mindnote|slides|file>] \
[--space-id <space_id>] \
[--format json|pretty|table|csv|ndjson] \
@@ -17,8 +17,9 @@ lark-cli wiki +node-get \
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/<token>` or `https://feishu.cn/docx/<token>`) |
| `--obj-type` | enum | No | — | Needed when `--token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) |
| `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/<token>` or `https://feishu.cn/docx/<token>`). Matches the `--node-token` naming used by sibling `+node-delete` / `+node-copy` / `+move`. |
| `--token` | string | — (deprecated) | — | Deprecated original name; still accepted for backward compatibility but emits a `Flag --token has been deprecated, use --node-token instead` warning on stderr. New scripts should use `--node-token`. |
| `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) |
| `--space-id` | string | No | — | Optional cross-check: fail if the resolved node does not live in this space |
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func setWikiNodeCreateDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "wiki_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "wiki_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
// TestWikiNodeCreateDryRun pins the request shape and Validate behavior for
// `wiki +node-create`.
func TestWikiNodeCreateDryRun(t *testing.T) {
setWikiNodeCreateDryRunEnv(t)
t.Run("HappyPath_ExplicitSpaceID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--title", "TestDoc",
"--obj-type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/wiki/v2/spaces/123456/nodes", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "origin", gjson.Get(result.Stdout, "api.0.body.node_type").String())
assert.Equal(t, "docx", gjson.Get(result.Stdout, "api.0.body.obj_type").String())
assert.Equal(t, "TestDoc", gjson.Get(result.Stdout, "api.0.body.title").String())
})
t.Run("HappyPath_WithParentNodeToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--parent-node-token", "wikcnABC123",
"--title", "ChildDoc",
"--obj-type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// 2-step: resolve parent node -> create node
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/wiki/v2/spaces/get_node", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "wikcnABC123", gjson.Get(result.Stdout, "api.0.params.token").String())
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.1.method").String())
assert.Equal(t, "/open-apis/wiki/v2/spaces/123456/nodes", gjson.Get(result.Stdout, "api.1.url").String())
assert.Equal(t, "wikcnABC123", gjson.Get(result.Stdout, "api.1.body.parent_node_token").String())
})
t.Run("HappyPath_ShortcutNodeType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--node-type", "shortcut",
"--origin-node-token", "wikcnORIG",
"--title", "ShortcutDoc",
"--obj-type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "shortcut", gjson.Get(result.Stdout, "api.0.body.node_type").String())
assert.Equal(t, "wikcnORIG", gjson.Get(result.Stdout, "api.0.body.origin_node_token").String())
})
t.Run("RejectsShortcutWithoutOriginNodeToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--node-type", "shortcut",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, "--origin-node-token is required")
})
t.Run("RejectsOriginNodeTokenWithoutShortcutType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--origin-node-token", "wikcnORIG",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, "--origin-node-token can only be used when --node-type=shortcut")
})
t.Run("RejectsBotWithoutSpaceIDOrParentNodeToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, "bot identity requires --space-id or --parent-node-token")
})
t.Run("RejectsBotWithMyLibrarySpaceID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "my_library",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, "bot identity does not support --space-id my_library")
})
t.Run("RejectsInvalidObjType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--obj-type", "pdf",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, `"pdf"`)
assert.Contains(t, msg, "invalid value")
})
}
func validateWikiErrorMessage(r *clie2e.Result) string {
if msg := gjson.Get(r.Stdout, "error.message").String(); msg != "" {
return msg
}
if msg := gjson.Get(r.Stderr, "error.message").String(); msg != "" {
return msg
}
return r.Stdout + r.Stderr
}