mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 10:29:48 +08:00
Compare commits
1 Commits
main
...
codex/docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2487b4e1a6 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,29 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.67] - 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.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
@@ -1421,7 +1398,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.67",
|
||||
"version": "1.0.66",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 flag(subscribe 的 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 型 --json(record-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 型 --json(subscribe 形态:无自定义 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)
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ func v2FetchFlags() []common.Flag {
|
||||
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
|
||||
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
|
||||
{Name: "start-block-id", Desc: "range/section anchor block id; range also accepts #share-xxx/#part-xxx selection anchors"},
|
||||
{Name: "end-block-id", Desc: "range end block id; -1 means through document end; selection anchors are not supported"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
|
||||
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
|
||||
@@ -151,12 +151,12 @@ func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||||
|
||||
// buildReadOption 拼装 read_option JSON;full/空模式返回 nil,让服务端走默认全文路径。
|
||||
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
mode := effectiveFetchReadMode(runtime)
|
||||
if mode == "" || mode == "full" {
|
||||
return nil
|
||||
}
|
||||
ro := map[string]interface{}{"read_mode": mode}
|
||||
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
|
||||
if v := effectiveFetchStartBlockID(runtime, mode); v != "" {
|
||||
ro["start_block_id"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
|
||||
@@ -177,6 +177,77 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return ro
|
||||
}
|
||||
|
||||
func effectiveFetchReadMode(runtime *common.RuntimeContext) string {
|
||||
mode := rawFetchReadMode(runtime)
|
||||
if shouldUseDocSelectionAnchor(runtime, mode) {
|
||||
if anchor, _ := docSelectionAnchorStartBlockID(runtime); anchor != "" {
|
||||
return "range"
|
||||
}
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func rawFetchReadMode(runtime *common.RuntimeContext) string {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
if mode == "" {
|
||||
return "full"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func effectiveFetchStartBlockID(runtime *common.RuntimeContext, mode string) string {
|
||||
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
|
||||
if anchor, ok, _ := parseFetchSelectionAnchor(v, "--start-block-id"); ok {
|
||||
return anchor
|
||||
}
|
||||
return v
|
||||
}
|
||||
if mode == "range" && shouldUseDocSelectionAnchor(runtime, rawFetchReadMode(runtime)) {
|
||||
if anchor, _ := docSelectionAnchorStartBlockID(runtime); anchor != "" {
|
||||
return anchor
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shouldUseDocSelectionAnchor(runtime *common.RuntimeContext, mode string) bool {
|
||||
if runtime.Changed("start-block-id") || runtime.Changed("end-block-id") {
|
||||
return false
|
||||
}
|
||||
if runtime.Changed("scope") {
|
||||
return mode == "range"
|
||||
}
|
||||
return mode == "" || mode == "full"
|
||||
}
|
||||
|
||||
func docSelectionAnchorStartBlockID(runtime *common.RuntimeContext) (string, error) {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
anchor, ok, err := parseFetchSelectionAnchor(ref.Fragment, "--doc")
|
||||
if err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
return anchor, nil
|
||||
}
|
||||
|
||||
func parseFetchSelectionAnchor(raw, param string) (string, bool, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
value = strings.TrimPrefix(value, "#")
|
||||
for _, prefix := range []string{"share-", "part-"} {
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
continue
|
||||
}
|
||||
anchorID := strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
if anchorID == "" {
|
||||
return "", false, errs.NewValidationError(errs.SubtypeInvalidArgument, "selection anchor id is required after %s", prefix).WithParam(param)
|
||||
}
|
||||
return prefix + anchorID, true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// effectiveFetchDetail degrades detail options that cannot be represented by
|
||||
// non-XML exports. The original flag value is left intact so callers can still
|
||||
// surface an explicit warning in execute output.
|
||||
@@ -208,7 +279,10 @@ func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[str
|
||||
|
||||
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
|
||||
func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
mode := effectiveFetchReadMode(runtime)
|
||||
if err := validateFetchSelectionAnchorUsage(runtime, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == "" || mode == "full" {
|
||||
return nil
|
||||
}
|
||||
@@ -227,7 +301,7 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
case "outline":
|
||||
return nil
|
||||
case "range":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
|
||||
if effectiveFetchStartBlockID(runtime, mode) == "" &&
|
||||
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id").WithParams(
|
||||
errs.InvalidParam{Name: "--start-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"},
|
||||
@@ -249,3 +323,42 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --scope %q", mode).WithParam("--scope")
|
||||
}
|
||||
}
|
||||
|
||||
func validateFetchSelectionAnchorUsage(runtime *common.RuntimeContext, mode string) error {
|
||||
startBlockID := strings.TrimSpace(runtime.Str("start-block-id"))
|
||||
endBlockID := strings.TrimSpace(runtime.Str("end-block-id"))
|
||||
|
||||
startAnchor, startIsAnchor, err := parseFetchSelectionAnchor(startBlockID, "--start-block-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, endIsAnchor, err := parseFetchSelectionAnchor(endBlockID, "--end-block-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if endIsAnchor {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-block-id does not support selection anchors; pass #share/#part through --start-block-id with --scope range").WithParam("--end-block-id")
|
||||
}
|
||||
if !startIsAnchor {
|
||||
_, _, err := parseFetchSelectionAnchorFromDoc(runtime)
|
||||
return err
|
||||
}
|
||||
if mode != "range" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-block-id selection anchor %q requires --scope range", startAnchor).WithParam("--start-block-id")
|
||||
}
|
||||
if endBlockID != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-block-id selection anchor %q cannot be combined with --end-block-id", startAnchor).WithParams(
|
||||
errs.InvalidParam{Name: "--start-block-id", Reason: "selection anchors define the complete selected range"},
|
||||
errs.InvalidParam{Name: "--end-block-id", Reason: "remove --end-block-id when --start-block-id is a selection anchor"},
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFetchSelectionAnchorFromDoc(runtime *common.RuntimeContext) (string, bool, error) {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return "", false, nil
|
||||
}
|
||||
return parseFetchSelectionAnchor(ref.Fragment, "--doc")
|
||||
}
|
||||
|
||||
@@ -180,6 +180,63 @@ func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyUsesSelectionAnchorFragmentAsRangeStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"read_mode": "range",
|
||||
"start_block_id": "share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
}
|
||||
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("read_option = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyExplicitFullIgnoresSelectionAnchorFragment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse")
|
||||
mustSetFetchFlag(t, runtime, "scope", "full")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["read_option"]; ok {
|
||||
t.Fatalf("did not expect read_option for explicit full scope: %#v", body["read_option"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyDoesNotAutoReadOrdinaryFragment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#blk_plain")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["read_option"]; ok {
|
||||
t.Fatalf("did not expect read_option for ordinary URL fragment: %#v", body["read_option"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReadOptionNormalizesExplicitSelectionAnchorStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "scope", "range")
|
||||
mustSetFetchFlag(t, runtime, "start-block-id", "#part-CUE3d6Ykno2fkexEvt8cGF8Wnse")
|
||||
|
||||
want := map[string]interface{}{
|
||||
"read_mode": "range",
|
||||
"start_block_id": "part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
}
|
||||
if got := buildReadOption(runtime); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildReadOption() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReadOptionModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -321,6 +378,31 @@ func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
|
||||
},
|
||||
wantParam: "--keyword",
|
||||
},
|
||||
{
|
||||
name: "selection anchor cannot be end block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"end-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
},
|
||||
wantParam: "--end-block-id",
|
||||
},
|
||||
{
|
||||
name: "selection anchor start cannot combine with end block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
"end-block-id": "blk_end",
|
||||
},
|
||||
wantParams: []string{"--start-block-id", "--end-block-id"},
|
||||
},
|
||||
{
|
||||
name: "selection anchor start requires range",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
},
|
||||
wantParam: "--start-block-id",
|
||||
},
|
||||
{
|
||||
name: "section needs start block",
|
||||
setFlags: map[string]string{
|
||||
@@ -375,6 +457,19 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
"end-block-id": "blk_end",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with selection anchor start",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default scope with selection anchor fragment",
|
||||
setFlags: map[string]string{
|
||||
"doc": "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
@@ -884,6 +979,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
|
||||
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
|
||||
cmd.Flags().String("detail", fetchDefault("detail"), "")
|
||||
cmd.Flags().String("lang", fetchDefault("lang"), "")
|
||||
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
const docsSceneContextKey = "lark_cli_docs_scene"
|
||||
|
||||
type documentRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
Kind string
|
||||
Token string
|
||||
Fragment string
|
||||
}
|
||||
|
||||
func parseDocumentRef(input string) (documentRef, error) {
|
||||
@@ -28,13 +29,13 @@ func parseDocumentRef(input string) (documentRef, error) {
|
||||
}
|
||||
|
||||
if token, ok := extractDocumentToken(raw, "/wiki/"); ok {
|
||||
return documentRef{Kind: "wiki", Token: token}, nil
|
||||
return documentRef{Kind: "wiki", Token: token, Fragment: extractDocumentFragment(raw)}, nil
|
||||
}
|
||||
if token, ok := extractDocumentToken(raw, "/docx/"); ok {
|
||||
return documentRef{Kind: "docx", Token: token}, nil
|
||||
return documentRef{Kind: "docx", Token: token, Fragment: extractDocumentFragment(raw)}, nil
|
||||
}
|
||||
if token, ok := extractDocumentToken(raw, "/doc/"); ok {
|
||||
return documentRef{Kind: "doc", Token: token}, nil
|
||||
return documentRef{Kind: "doc", Token: token, Fragment: extractDocumentFragment(raw)}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
|
||||
@@ -62,6 +63,14 @@ func extractDocumentToken(raw, marker string) (string, bool) {
|
||||
return token, true
|
||||
}
|
||||
|
||||
func extractDocumentFragment(raw string) string {
|
||||
idx := strings.Index(raw, "#")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(raw[idx+1:])
|
||||
}
|
||||
|
||||
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
|
||||
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
|
||||
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id
|
||||
|
||||
@@ -13,11 +13,12 @@ func TestParseDocumentRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr string
|
||||
name string
|
||||
input string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantFragment string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "docx url",
|
||||
@@ -31,6 +32,13 @@ func TestParseDocumentRef(t *testing.T) {
|
||||
wantKind: "wiki",
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "wiki url with selection anchor",
|
||||
input: "https://example.larksuite.com/wiki/xxxxxx#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
wantKind: "wiki",
|
||||
wantToken: "xxxxxx",
|
||||
wantFragment: "share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
},
|
||||
{
|
||||
name: "doc url",
|
||||
input: "https://example.larksuite.com/doc/xxxxxx",
|
||||
@@ -73,6 +81,9 @@ func TestParseDocumentRef(t *testing.T) {
|
||||
if got.Token != tt.wantToken {
|
||||
t.Fatalf("parseDocumentRef(%q) token = %q, want %q", tt.input, got.Token, tt.wantToken)
|
||||
}
|
||||
if got.Fragment != tt.wantFragment {
|
||||
t.Fatalf("parseDocumentRef(%q) fragment = %q, want %q", tt.input, got.Fragment, tt.wantFragment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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"]` |
|
||||
|
||||
@@ -43,6 +43,35 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunSelectionAnchorFragmentBecomesRangeStart(t *testing.T) {
|
||||
setDocsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/wikcnDryRun/fetch" {
|
||||
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.read_option.read_mode").String(); got != "range" {
|
||||
t.Fatalf("read_mode=%q, want range\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.read_option.start_block_id").String(); got != "share-CUE3d6Ykno2fkexEvt8cGF8Wnse" {
|
||||
t.Fatalf("start_block_id=%q, want selection anchor\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func setDocsDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
Reference in New Issue
Block a user