Compare commits

..

1 Commits

Author SHA1 Message Date
liangshuo-1
003625860b chore: release v1.0.68 2026-07-08 21:04:43 +08:00
22 changed files with 38 additions and 626 deletions

View File

@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file.
## [v1.0.68] - 2026-07-08
### Features
- **mail**: add message modify and trash shortcuts (#1567)
- support whiteboard file inputs in docs XML (#1784)
- **vc**: refine meeting-events output and reaction forwarding (#1674)
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
### Bug Fixes
- accept opaque wiki node tokens (#1789)
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
- preserve original filename in multipart file upload (#1767)
### Documentation
- restore one-time authorization guidance in lark-apps skill (#1794)
### Misc
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
## [v1.0.67] - 2026-07-08
### Features
@@ -1421,6 +1444,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65

View File

@@ -113,7 +113,6 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},

View File

@@ -17,7 +17,6 @@ var driveCodeMeta = map[int]CodeMeta{
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied

View File

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

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
func mountBaseShortcutFlags(t *testing.T, s common.Shortcut, name string) *cobra.Command {
t.Helper()
parent := &cobra.Command{Use: "test"}
s.Mount(parent, &cmdutil.Factory{})
cmd, _, err := parent.Find([]string{name})
if err != nil {
t.Fatalf("Find(%s) error = %v", name, err)
}
return cmd
}
// record-list 获得 --json 简写
func TestRecordListRegistersJSONShorthand(t *testing.T) {
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("+record-list missing --json shorthand")
}
if fl.Usage != "shorthand for --format json" {
t.Errorf("usage = %q, want shorthand", fl.Usage)
}
if def := cmd.Flags().Lookup("format").DefValue; def != "markdown" {
t.Errorf("format default = %q, want markdown (unchanged)", def)
}
}
// record-search / record-get 的 --json 保持请求体语义,不被覆盖(回归锚点)
func TestRecordSearchGetKeepRequestBodyJSON(t *testing.T) {
for _, tc := range []struct {
name string
shortcut common.Shortcut
cmdName string
}{
{"record-search", BaseRecordSearch, "+record-search"},
{"record-get", BaseRecordGet, "+record-get"},
} {
cmd := mountBaseShortcutFlags(t, tc.shortcut, tc.cmdName)
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatalf("%s: --json (request body) missing", tc.name)
}
if strings.Contains(fl.Usage, "shorthand") {
t.Fatalf("%s: request-body --json overwritten by shorthand: %q", tc.name, fl.Usage)
}
if fl.Value.Type() != "string" {
t.Fatalf("%s: --json type = %q, want string", tc.name, fl.Value.Type())
}
}
}
// Enum 已接入help 描述携带枚举后缀(框架对带 Enum 的 flag 自动追加 " (markdown|json)"
func TestRecordReadFormatFlagCarriesEnum(t *testing.T) {
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
usage := cmd.Flags().Lookup("format").Usage
if !strings.Contains(usage, "(markdown|json)") {
t.Fatalf("format usage missing enum suffix: %q", usage)
}
}

View File

@@ -85,7 +85,6 @@ func recordReadFormatFlag() common.Flag {
return common.Flag{
Name: "format",
Default: "markdown",
Enum: []string{"markdown", "json"},
Desc: "output format: markdown (default) | json",
}
}

View File

@@ -1027,7 +1027,6 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
}
rctx.larkSDK = sdk
applyJSONShorthand(cmd, s)
rctx.Format = rctx.Str("format")
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx, nil
@@ -1173,75 +1172,6 @@ func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut)
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
}
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
// pretty-print switch or base +record-search's request-body payload).
// Framework-injected flags never appear in s.Flags, so this cleanly separates
// "self-declared json" from "injected shorthand".
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "json" {
return true
}
}
return false
}
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
// "json": a self-declared format supports it only when its Enum lists "json";
// a framework-injected default format (no format entry in s.Flags) always does.
func shortcutFormatSupportsJSON(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "format" {
return slices.Contains(fl.Enum, "json")
}
}
return true // framework-injected: json (default) | pretty | table | ndjson | csv
}
// ensureJSONShorthand registers --json as a shorthand for --format json when:
// 1. the command has a format flag (self-declared or framework-injected), AND
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
// 3. no flag named "json" is registered yet — pflag panics on duplicate
// registration, and commands that declare their own --json (event
// +subscribe, base +record-search/-get) keep their custom semantics.
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
// A shortcut that declares its own "json" flag defines custom semantics
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
if shortcutDeclaresJSONFlag(s) {
return
}
if cmd.Flags().Lookup("format") == nil {
return
}
if !shortcutFormatSupportsJSON(s) {
return
}
// Safety net: pflag panics on duplicate registration.
if cmd.Flags().Lookup("json") != nil {
return
}
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
// applyJSONShorthand folds the injected --json shorthand into the format flag
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
// "json". An explicitly passed --format always wins over the shorthand (the
// shorthand only fills in when the user did not choose a format). Shortcuts
// that declare their own "json" flag keep its custom semantics untouched.
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
if shortcutDeclaresJSONFlag(s) {
return
}
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
return
}
if set, _ := cmd.Flags().GetBool("json"); set {
_ = cmd.Flags().Set("format", "json")
}
}
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
for _, fl := range s.Flags {
desc := fl.Desc
@@ -1305,8 +1235,10 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
}
ensureJSONShorthand(cmd, s)
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}

View File

@@ -1,200 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
const jsonShorthandUsage = "shorthand for --format json"
func mountTestShortcut(t *testing.T, s Shortcut) *cobra.Command {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
s.Mount(parent, f)
cmd, _, err := parent.Find([]string{s.Command})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
return cmd
}
// 自定义 format 且 Enum 含 json → 注册简写(本次修复的核心行为)
func TestJSONShorthand_CustomFormatWithJSONEnum_Registered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "mail", Command: "+fake-triage", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("--json not registered for custom-format shortcut whose Enum contains json")
}
if fl.Usage != jsonShorthandUsage {
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
}
// 默认输出格式不被改变
if def := cmd.Flags().Lookup("format").DefValue; def != "table" {
t.Errorf("format default = %q, want table", def)
}
}
// 自定义 format 但 Enum 不含 json → 不注册
func TestJSONShorthand_CustomFormatWithoutJSONEnum_NotRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "x", Command: "+no-json", Description: "x",
Flags: []Flag{{Name: "format", Default: "csv", Enum: []string{"csv", "table"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
if cmd.Flags().Lookup("json") != nil {
t.Fatal("--json must NOT be registered when format Enum lacks json")
}
}
// 自定义 format 但无 Enum现状 triage 形态)→ 不注册Enum 是判定依据)
func TestJSONShorthand_CustomFormatNoEnum_NotRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "x", Command: "+legacy", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
if cmd.Flags().Lookup("json") != nil {
t.Fatal("--json must NOT be registered when format has no Enum metadata")
}
}
// 自声明 json flagsubscribe 的 pretty / record-search 的请求体)→ 不覆盖、不 panic、语义保留
func TestJSONShorthand_SelfDeclaredJSON_Preserved(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "event", Command: "+fake-subscribe", Description: "x",
Flags: []Flag{
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("self-declared --json missing")
}
if fl.Usage != "pretty-print JSON instead of NDJSON" {
t.Errorf("self-declared --json usage overwritten: %q", fl.Usage)
}
}
// parseMounted mounts the shortcut and parses args against the command's FlagSet
// (registration side effects included), without executing RunE.
func parseMounted(t *testing.T, s Shortcut, args []string) *cobra.Command {
t.Helper()
cmd := mountTestShortcut(t, s)
if err := cmd.ParseFlags(args); err != nil {
t.Fatalf("ParseFlags(%v) error = %v", args, err)
}
return cmd
}
func customFormatShortcut() Shortcut {
return Shortcut{
Service: "mail", Command: "+fake-triage", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
}
// --json 单独使用 → format 归一化为 json
func TestApplyJSONShorthand_JSONAlone_SetsFormatJSON(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
t.Fatalf("format = %q, want json", got)
}
}
// 显式 --format 优先于 --json 简写:--format table --json → table
func TestApplyJSONShorthand_ExplicitFormatWins(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--format", "table", "--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
t.Fatalf("format = %q, want table (explicit --format must win)", got)
}
}
// --format json --json → json一致无冲突
func TestApplyJSONShorthand_ExplicitJSONFormatConsistent(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--format", "json", "--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
t.Fatalf("format = %q, want json", got)
}
}
// 均不传 → 默认值不变
func TestApplyJSONShorthand_NoFlags_DefaultUntouched(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, nil)
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
t.Fatalf("format = %q, want table (default untouched)", got)
}
}
// 自声明 string 型 --jsonrecord-search 形态format+json 双声明)→ 归一化跳过
func TestApplyJSONShorthand_SelfDeclaredStringJSON_Skipped(t *testing.T) {
s := Shortcut{
Service: "base", Command: "+fake-record-search", Description: "x",
Flags: []Flag{
{Name: "format", Default: "markdown", Enum: []string{"markdown", "json"}, Desc: "fmt"},
{Name: "json", Desc: "request body JSON object"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
cmd := parseMounted(t, s, []string{"--json", `{"keyword":"Alice"}`})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "markdown" {
t.Fatalf("format = %q, want markdown (self-declared json must not normalize)", got)
}
if got := cmd.Flags().Lookup("json").Value.String(); got != `{"keyword":"Alice"}` {
t.Fatalf("request-body --json corrupted: %q", got)
}
}
// 自声明 bool 型 --jsonsubscribe 形态:无自定义 format框架注入 format→ 归一化跳过
func TestApplyJSONShorthand_SelfDeclaredBoolJSON_Skipped(t *testing.T) {
s := Shortcut{
Service: "event", Command: "+fake-subscribe", Description: "x",
Flags: []Flag{
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
cmd := parseMounted(t, s, []string{"--json"})
applyJSONShorthand(cmd, &s)
// 注入的 format 默认即 json这里断言的是 Changed 状态未被归一化污染
if cmd.Flags().Changed("format") {
t.Fatal("normalization must not touch format for shortcuts declaring their own --json")
}
}
// 无自定义 format普通命令→ 注入默认 format + 简写(现状回归)
func TestJSONShorthand_DefaultInjectedFormat_StillRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "im", Command: "+plain", Description: "x",
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("--json missing on default-format shortcut (regression)")
}
if fl.Usage != jsonShorthandUsage {
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
}
}

View File

@@ -623,10 +623,6 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
decision.Class = "rate_limited"
decision.Terminal = true
case problem.Code == 1062507:
decision.Class = "parent_sibling_limit"
decision.Terminal = true
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
decision.Class = "file_size_limit"
case problem.Code == 1062009:

View File

@@ -1334,75 +1334,6 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
}
}
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
t.Fatalf("MkdirAll a: %v", err)
}
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
t.Fatalf("MkdirAll b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 1062507,
"msg": "parent node out of sibling num.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
}
for _, item := range items {
if item["rel_path"] == "b" {
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
}
}
}
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -1,112 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// help 必须列出 --json 简写
func TestMailTriageHelpListsJSONShorthand(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
if err := runMountedMailShortcutWithCobraOutput(t, MailTriage, []string{"+triage", "-h"}, f, stdout); err != nil {
t.Fatalf("help returned error: %v", err)
}
if !strings.Contains(stdout.String(), "shorthand for --format json") {
t.Fatalf("triage help missing --json shorthand\n%s", stdout.String())
}
}
func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
if err := runMountedMailShortcutWithCobraOutput(t, MailWatch, []string{"+watch", "-h"}, f, stdout); err != nil {
t.Fatalf("help returned error: %v", err)
}
if !strings.Contains(stdout.String(), "shorthand for --format json") {
t.Fatalf("watch help missing --json shorthand\n%s", stdout.String())
}
}
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
if err != nil {
t.Fatalf("triage --json returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
}
if !strings.Contains(stdout.String(), `"messages"`) {
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
}
}
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
f1, stdout1, _, _ := mailShortcutTestFactory(t)
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1", "--dry-run"}, f1, stdout1); err != nil {
t.Fatalf("--json --dry-run error: %v", err)
}
f2, stdout2, _, _ := mailShortcutTestFactory(t)
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "json", "--max", "1", "--dry-run"}, f2, stdout2); err != nil {
t.Fatalf("--format json --dry-run error: %v", err)
}
if stdout1.String() != stdout2.String() {
t.Fatalf("dry-run outputs differ:\n--json:\n%s\n--format json:\n%s", stdout1.String(), stdout2.String())
}
}
// 优先级验证:显式 --format table 优先,--json 让位 → 仍走 table 路径
func TestMailTriageExplicitTableWinsOverJSONShorthand(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "table", "--json", "--max", "1"}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
if !strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("explicit --format table must win over --json (expected table hint)\nstderr=%s", stderr.String())
}
}
// 错误验证Enum 硬校验
func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "bogus", "--max", "1", "--dry-run"}, f, stdout)
if err == nil {
t.Fatal("expected validation error for --format bogus")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T, want typed errs problem carrier", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if ve.Param != "--format" {
t.Fatalf("param = %q, want --format", ve.Param)
}
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
t.Fatalf("message = %q, want enum validation message", problem.Message)
}
if !strings.Contains(problem.Message, "table, json, data") {
t.Fatalf("message = %q, want allowed values list", problem.Message)
}
}

View File

@@ -55,7 +55,7 @@ var MailTriage = common.Shortcut{
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
{Name: "page-size", Type: "int", Desc: "alias for --max"},
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},

View File

@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},

View File

@@ -46,7 +46,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md),未给 URL/token 时先用 `lark-cli drive +search --doc-types mindnote` 定位 Mindnote 文档 token;新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理

View File

@@ -9,32 +9,6 @@
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
## 获取 `mindnote_id`
`--mindnote-id`**Mindnote 文档 token**,不是节点 ID。`lark-cli mindnotes` 只负责读取和写入思维笔记内部节点,不提供按标题列出 Mindnote 文档的入口;找文档要先走 Drive。
```bash
# 用户给了 Mindnote URL或给了可能包着 Mindnote 的 Wiki URL
lark-cli drive +inspect --url "<mindnote_or_wiki_url>"
# 用户只给了标题、关键词,或需要在云空间里找思维笔记
lark-cli drive +search --query "<关键词>" --doc-types mindnote --format table
# 用户想浏览自己负责的思维笔记
lark-cli drive +search --doc-types mindnote --mine --sort edit_time --format table
```
处理规则:
- 普通 Mindnote URL`drive +inspect` 返回的 Mindnote token 可作为 `--mindnote-id`
- Wiki URL不要把 `/wiki/` 路径里的 wiki token 当作 `--mindnote-id`;必须先 `drive +inspect` 解包,确认底层类型是 `mindnote` 后再使用返回的真实 token。直接把 wiki token 传给 `mindnotes nodes list` 通常会返回 `3410003 resource not found`
- 标题 / 关键词:用 `drive +search --doc-types mindnote` 定位;多候选时先让用户确认目标,不要猜。
## 所需权限
- 读取节点:需要用户授权包含 `mindnote:node:read`
- 创建 / 更新节点:需要用户授权包含对应写入 scope如果命令返回 `missing_scope`,按 [`lark-shared`](../../lark-shared/SKILL.md) 的授权恢复流程,用错误里的 `hint``missing_scopes` 重新执行 `lark-cli auth login --scope ...`
## 命令
```bash
@@ -122,8 +96,8 @@ lark-cli mindnotes nodes create \
1. 先判断用户目标是不是“新建一个思维笔记”。
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
3. 如果是操作已有思维笔记,先按上方「获取 `mindnote_id`」定位 Mindnote 文档 token。
4. 确认目标类型**Mindnote**,把真实 Mindnote token 作为 `--mindnote-id`
3. 如果是操作已有思维笔记,先通过 token 类别判断
4. 确认是 **Mindnote**再拿到 `mindnote_id`
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`
7. 再执行 `mindnotes nodes create`
@@ -136,5 +110,4 @@ lark-cli mindnotes nodes create \
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
- [lark-drive](../../lark-drive/SKILL.md) — 搜索和解析 Mindnote / Wiki 等云空间资源
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -21,8 +21,6 @@ metadata:
## 快速决策
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token先用 `lark-cli drive +inspect` 获取底层 `token``type`,不要把 wiki token 直接当 `file_token``params.file_token` 传源文档 token`data.folder_token` 传目标文件夹 token`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
@@ -164,7 +162,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
>
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`使用前先读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md),按模板通过 `--params` 传并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)模板通过 `--params` 传 `folder_token` / `page_token`并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
### files
@@ -206,12 +204,10 @@ lark-cli drive <resource> <method> [flags] # 调用 API
### file.statistics
- `get` — 获取文件统计信息
- 获取 docx / 文件统计信息时,建议优先使用 typed flags`lark-cli drive file.statistics get --file-token <token> --file-type <type> --format json``--params` JSON 也支持,适合批量拼装或 raw 参数场景。
### file.view_records
- `list` — 获取文档的访问者记录
- 查看 docx 最近访问记录、返回 open_id、最多 N 条时,建议优先使用 typed flags`lark-cli drive file.view_records list --file-token <docx_token> --file-type docx --page-size <N> --viewer-id-type open_id --format json``--params` JSON 也支持,适合批量拼装、分页续跑或 raw 参数场景。
### file.comment.reply.reactions

View File

@@ -7,18 +7,6 @@
> [!CAUTION]
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
> “目标明确”表示用户给出了可解析为 `file-token` + `type` 的具体 URL/token或对你刚列出的可解析资源列表逐项/整批确认删除。按“没用的”“临时的”“疑似重复的”“全部旧文件”等描述搜索出来的候选属于待确认目标;这类请求先列候选、说明筛选依据和影响范围,然后停止等待确认。
## 删除前门槛
执行 `drive +delete --yes` 前同时满足:
| 条件 | 可执行信号 |
|------|------------|
| 具体目标 | 单个可解析为 `file-token` + `type` 的 URL/token或用户确认过且可解析的资源列表 |
| 执行确认 | 用户在本轮明确说确认删除这些具体目标 |
若缺少任一条件,使用 `drive +search``drive +inspect` 或只读 API 收集候选并回复待确认清单启发式规则打开时间、标题模式、owner、文件类型等只能作为候选筛选依据不能升级为删除确认。执行 `drive +delete` 时必须使用解析后的 `--file-token``--type`
## 命令

View File

@@ -41,37 +41,12 @@ lark-cli drive files list \
也可以省略 `folder_token` 字段来请求根目录,但在 Agent 编排中建议显式传空字符串,避免把“忘记传参数”和“确认请求根目录”混在一起。
## 按时间排序
默认不要传 `order_by` / `direction`;服务端会按默认顺序返回。只有用户明确要求按创建时间或编辑时间排序时,才使用服务端排序参数。
按创建时间升序列出当前文件夹直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","order_by":"CreatedTime","direction":"ASC","page_size":200}' \
--format json
```
按编辑时间降序列出当前文件夹直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","order_by":"EditedTime","direction":"DESC","page_size":200}' \
--format json
```
以上示例返回排序后的当前页;如果返回 `has_more=true`,保持相同 `folder_token` / `order_by` / `direction` / `page_size`,把 `next_page_token` 放入 `page_token` 继续翻页。
## 参数规则
1. `folder_token` 必须放在 `--params` JSON 里;不要使用不存在的 `--folder-token` flag。
2. `page_token` 必须放在 `--params` JSON 里;不要依赖 shell 变量拼接不完整的 JSON。
3. 默认不要传 `order_by` / `direction`;只有用户明确要求按创建时间 / 编辑时间排序时才使用服务端排序参数
4. 排序参数映射:创建时间 -> `order_by:"CreatedTime"`;编辑时间 / 修改时间 -> `order_by:"EditedTime"`;升序 -> `direction:"ASC"`;降序 -> `direction:"DESC"`。不要省略排序参数后再用 Python / shell 客户端排序替代
5. 排序查询建议带 `page_size:200` 减少翻页;只有用户要求完整分页、递归盘点、大目录全量导出,或当前页返回 `has_more=true` 后继续翻页时,才加入 `page_token`
6. `page_size` 在分页、递归盘点或全量导出时建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
7. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
3. `page_size` 建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因
4. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构
## 返回结构与解析

View File

@@ -47,6 +47,4 @@ JSON 输出包含以下字段:
- `--url` 为必填参数
-`--url` 是 bare token非完整 URL`--type` 也是必填的
- wiki URL 会自动调用 `get_node` API 解包,输出中 `type``token` 是底层文档的类型和 token
- `+inspect` 只用于识别/消歧;如果任务已能通过 URL 路径形态完成路由判断,不必把它作为所有 Drive 操作的通用前置步骤
- `+inspect` 失败后不要自动切到写接口继续尝试先按错误提示处理权限、scope 或链接问题
- 支持 `--dry-run` 查看将调用的 API 步骤

View File

@@ -10,18 +10,6 @@
如果用户只是想向文档 owner 申请访问权限,优先使用 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)。
## 公开权限修改前门槛
公开权限修改是高风险写操作。执行 `drive permission.public patch --yes` 前同时确认:
| 条件 | 可执行信号 |
|------|------------|
| 具体目标 | 单个 URL/token或用户确认过的资源列表 |
| 公开范围 | 用户明确选择组织内/互联网、可读/可编辑等具体 `link_share_entity` 档位 |
| 执行确认 | 用户在本轮确认按该目标和范围执行 |
“开放一下”“共享给大家”“让大家能看”只表达目标状态不包含具体公开范围。先列出可选范围并停止等待用户选择公开档位必须来自用户选择CLI 的 `--yes` 只表示已获得用户对该档位的执行确认。
## 公开权限错误码
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope它们通常表示租户、对外分享或文档密级策略拦截。

View File

@@ -143,7 +143,6 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试检查目标文件夹权限、身份类型user / bot和资源可见性 |
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
| `parent_sibling_limit` | `1062507` | 目标父文件夹单层子节点数量超过上限 | 停止重试,清理目标目录、换一个 `--folder-token`,或把上传内容拆到多个子目录 |
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |

View File

@@ -47,7 +47,7 @@ lark-cli mail +watch --print-output-schema
|------|------|------|
| `--mailbox <id>` | `me` | 订阅目标邮箱 |
| `--msg-format <mode>` | `metadata` | 输出模式:`metadata` / `minimal` / `plain_text_full` / `full` / `event` |
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--format <mode>` | `table` | 输出样式:`table` / `json` / `data` |
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |