Compare commits

..

3 Commits

Author SHA1 Message Date
fangshuyu
f437118473 fix: centralize resource URL parsing 2026-07-09 10:18:49 +08:00
zhaojunlin0405
c04da4723a fix: register and consume --json shorthand for custom-format shortcuts (#1737)
* fix: decouple --json shorthand registration from default format injection

* fix: fold --json shorthand into format flag before consumption

* fix: enable --json shorthand for mail +triage and mail +watch

* fix: enable --json shorthand for base +record-list

* docs: document --json shorthand for triage, watch and record-list

* docs: clarify record-list JSON output for script consumption

* docs: guide agents to JSON output for machine consumption scenarios

* test: make test comments self-contained

* test: assert typed error metadata in enum validation test

* docs: correct mail +watch --format default and enum in skill doc

* docs: keep --json shorthand undocumented as a silent fallback
2026-07-08 21:32:27 +08:00
liangshuo-1
a09388d035 chore: release v1.0.67 (#1808) 2026-07-08 21:04:22 +08:00
24 changed files with 614 additions and 169 deletions

View File

@@ -2,6 +2,29 @@
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
@@ -1398,6 +1421,7 @@ 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

View File

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

View File

@@ -271,6 +271,9 @@ func resolveBaseURL(u *url.URL) map[string]interface{} {
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
token := firstPathSegmentAfter(u.Path, "/wiki/")
if err := runtime.EnsureScopes([]string{"wiki:node:retrieve"}); err != nil {
return nil, err
}
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
if err != nil {
return nil, err
@@ -518,14 +521,8 @@ func pathSegmentExists(path, prefix string) bool {
func firstPathSegmentAfter(path, prefix string) string {
path = normalizeResolvePath(path)
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := path[len(prefix):]
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
rest = rest[:idx]
}
return strings.TrimSpace(rest)
token, _ := common.PathSegmentAfterPrefix(path, prefix)
return token
}
func valueOrUnknown(s string) string {

View File

@@ -0,0 +1,73 @@
// 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,6 +85,7 @@ func recordReadFormatFlag() common.Flag {
return common.Flag{
Name: "format",
Default: "markdown",
Enum: []string{"markdown", "json"},
Desc: "output format: markdown (default) | json",
}
}

View File

@@ -78,6 +78,7 @@ var urlPathToType = []struct {
{"/chat/drive/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/spreadsheets/", "sheet"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
@@ -117,21 +118,30 @@ func ParseResourceURL(rawURL string) (ResourceRef, bool) {
path := u.Path
for _, mapping := range urlPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
if token, ok := PathSegmentAfterPrefix(path, mapping.Prefix); ok {
return ResourceRef{Type: mapping.Type, Token: token}, true
}
token := path[len(mapping.Prefix):]
// Trim trailing slashes and stop at the next path segment boundary.
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return ResourceRef{}, false
}
return ResourceRef{Type: mapping.Type, Token: token}, true
}
return ResourceRef{}, false
}
// PathSegmentAfterPrefix returns the first non-empty path segment after prefix.
// It only matches when path itself starts with prefix; query strings and
// fragments must be parsed by the caller and are intentionally ignored.
func PathSegmentAfterPrefix(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
// Trim trailing slashes and stop at the next path segment boundary.
rest = strings.TrimRight(rest, "/")
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
rest = rest[:idx]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}

View File

@@ -23,6 +23,7 @@ func TestParseResourceURL(t *testing.T) {
{"docx", "https://xxx.feishu.cn/docx/doxcnABC", "docx", "doxcnABC", true},
{"doc", "https://xxx.feishu.cn/doc/doccnABC", "doc", "doccnABC", true},
{"sheet", "https://xxx.feishu.cn/sheets/shtcnABC", "sheet", "shtcnABC", true},
{"sheet via /spreadsheets/", "https://xxx.feishu.cn/spreadsheets/shtcnABC", "sheet", "shtcnABC", true},
{"bitable via /base/", "https://xxx.feishu.cn/base/bascnABC", "bitable", "bascnABC", true},
{"bitable via /bitable/", "https://xxx.feishu.cn/bitable/bascnABC", "bitable", "bascnABC", true},
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
@@ -41,6 +42,8 @@ func TestParseResourceURL(t *testing.T) {
// With query parameters
{"with query", "https://xxx.feishu.cn/docx/doxcnABC?from=wiki", "docx", "doxcnABC", true},
{"with fragment", "https://xxx.feishu.cn/docx/doxcnABC#section", "docx", "doxcnABC", true},
{"query path does not hijack type", "https://xxx.feishu.cn/docx/doxcnABC?next=/wiki/wikcnBAD", "docx", "doxcnABC", true},
{"fragment path does not hijack type", "https://xxx.feishu.cn/sheets/shtcnABC#/wiki/wikcnBAD", "sheet", "shtcnABC", true},
// With trailing slash
{"trailing slash", "https://xxx.feishu.cn/docx/doxcnABC/", "docx", "doxcnABC", true},

View File

@@ -1027,6 +1027,7 @@ 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
@@ -1172,6 +1173,75 @@ 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
@@ -1235,10 +1305,8 @@ 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

@@ -0,0 +1,200 @@
// 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

@@ -49,7 +49,10 @@ var DocMediaInsert = common.Shortcut{
Description: "Insert a local image or file into a Lark document (4-step orchestration + auto-rollback); appends to end by default, or inserts relative to a text selection with --selection-with-ellipsis",
Risk: "write",
Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{
"wiki:node:retrieve",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)"},
{Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file (macOS/Windows built-in; Linux requires xclip, xsel or wl-paste)"},
@@ -534,6 +537,9 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
case "wiki":
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
if err := runtime.EnsureScopes([]string{"wiki:node:retrieve"}); err != nil {
return "", err
}
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",

View File

@@ -59,7 +59,10 @@ var DocResourceDownload = common.Shortcut{
Description: "Download a document resource (type=cover downloads the cover image content)",
Risk: "read",
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{
"wiki:node:retrieve",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
@@ -158,7 +161,10 @@ var DocResourceUpdate = common.Shortcut{
Description: "Upload and update a document resource (type=cover)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{
"wiki:node:retrieve",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
@@ -260,7 +266,10 @@ var DocResourceDelete = common.Shortcut{
Description: "Delete a document resource (type=cover is idempotent when empty)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{
"wiki:node:retrieve",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},

View File

@@ -27,14 +27,13 @@ func parseDocumentRef(input string) (documentRef, error) {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
}
if token, ok := extractDocumentToken(raw, "/wiki/"); ok {
return documentRef{Kind: "wiki", Token: token}, nil
}
if token, ok := extractDocumentToken(raw, "/docx/"); ok {
return documentRef{Kind: "docx", Token: token}, nil
}
if token, ok := extractDocumentToken(raw, "/doc/"); ok {
return documentRef{Kind: "doc", Token: token}, nil
if ref, ok := common.ParseResourceURL(raw); ok {
switch ref.Type {
case "wiki", "docx", "doc":
return documentRef{Kind: ref.Type, Token: ref.Token}, nil
default:
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")
}
}
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")
@@ -46,22 +45,6 @@ func parseDocumentRef(input string) (documentRef, error) {
return documentRef{Kind: "docx", Token: raw}, nil
}
func extractDocumentToken(raw, marker string) (string, bool) {
idx := strings.Index(raw, marker)
if idx < 0 {
return "", false
}
token := raw[idx+len(marker):]
if end := strings.IndexAny(token, "/?#"); end >= 0 {
token = token[:end]
}
token = strings.TrimSpace(token)
if token == "" {
return "", false
}
return token, true
}
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id

View File

@@ -31,6 +31,18 @@ func TestParseDocumentRef(t *testing.T) {
wantKind: "wiki",
wantToken: "xxxxxx",
},
{
name: "query path does not hijack docx url",
input: "https://example.larksuite.com/docx/doxcn123?next=/wiki/wikcnBAD",
wantKind: "docx",
wantToken: "doxcn123",
},
{
name: "fragment path does not hijack docx url",
input: "https://example.larksuite.com/docx/doxcn123#/wiki/wikcnBAD",
wantKind: "docx",
wantToken: "doxcn123",
},
{
name: "doc url",
input: "https://example.larksuite.com/doc/xxxxxx",

View File

@@ -129,7 +129,8 @@ var DriveAddComment = common.Shortcut{
"docs:document.comment:create",
"docs:document.comment:write_only",
},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
@@ -515,29 +516,17 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
}
if token, ok := extractURLToken(raw, "/wiki/"); ok {
return commentDocRef{Kind: "wiki", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/sheets/"); ok {
return commentDocRef{Kind: "sheet", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/base/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/bitable/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/file/"); ok {
return commentDocRef{Kind: "file", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/slides/"); ok {
return commentDocRef{Kind: "slides", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/docx/"); ok {
return commentDocRef{Kind: "docx", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/doc/"); ok {
return commentDocRef{Kind: "doc", Token: token}, nil
if ref, ok := common.ParseResourceURL(raw); ok {
kind := ref.Type
if kind == "bitable" {
kind = "base"
}
switch kind {
case "wiki", "sheet", "base", "file", "slides", "docx", "doc":
return commentDocRef{Kind: kind, Token: ref.Token}, nil
default:
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
}
}
if strings.Contains(raw, "://") {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
@@ -583,6 +572,9 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
if err := runtime.EnsureScopes([]string{"wiki:node:retrieve"}); err != nil {
return resolvedCommentTarget{}, err
}
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
@@ -1257,19 +1249,3 @@ func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef)
runtime.Out(out, nil)
return nil
}
func extractURLToken(raw, marker string) (string, bool) {
idx := strings.Index(raw, marker)
if idx < 0 {
return "", false
}
token := raw[idx+len(marker):]
if end := strings.IndexAny(token, "/?#"); end >= 0 {
token = token[:end]
}
token = strings.TrimSpace(token)
if token == "" {
return "", false
}
return token, true
}

View File

@@ -98,6 +98,18 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "wiki",
wantToken: "xxxxxx",
},
{
name: "query path does not hijack docx url",
input: "https://example.larksuite.com/docx/doxcn123?next=/wiki/wikcnBAD",
wantKind: "docx",
wantToken: "doxcn123",
},
{
name: "fragment path does not hijack sheet url",
input: "https://example.larksuite.com/sheets/sht123#/wiki/wikcnBAD",
wantKind: "sheet",
wantToken: "sht123",
},
{
name: "raw token with type docx",
input: "xxxxxx",
@@ -2080,14 +2092,6 @@ func TestParseCommentDocRefPathLikeToken(t *testing.T) {
}
}
func TestExtractURLTokenEmptyAfterMarker(t *testing.T) {
t.Parallel()
_, ok := extractURLToken("https://example.com/sheets/", "/sheets/")
if ok {
t.Fatal("expected false for empty token after marker")
}
}
func TestSheetCommentExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -20,24 +20,6 @@ var permApplyTypes = []string{
"mindnote", "slides",
}
// permApplyURLMarkers maps document URL path markers to the `type` value the
// apply-permission endpoint expects. Markers are disjoint strings (each begins
// with "/" and ends with "/"), so a simple substring scan disambiguates them.
var permApplyURLMarkers = []struct {
Marker string
Type string
}{
{"/wiki/", "wiki"},
{"/docx/", "docx"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/file/", "file"},
{"/mindnote/", "mindnote"},
{"/slides/", "slides"},
{"/doc/", "doc"},
}
// resolvePermApplyTarget extracts (token, type) from a user-supplied --token
// value that may be either a bare token or a full document URL, plus an
// optional explicit --type. Explicit --type wins over URL inference.
@@ -48,21 +30,17 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
}
if strings.Contains(raw, "://") {
for _, m := range permApplyURLMarkers {
if tok, ok := extractURLToken(raw, m.Marker); ok {
token = tok
if explicitType == "" {
docType = m.Type
}
break
}
}
if token == "" {
ref, ok := common.ParseResourceURL(raw)
if !ok {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
).WithParam("--token")
}
token = ref.Token
if explicitType == "" {
docType = ref.Type
}
} else {
token = raw
}
@@ -76,9 +54,25 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
strings.Join(permApplyTypes, ", "),
).WithParam("--type")
}
if !isPermApplyType(docType) {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported --type %q; accepted values: %s",
docType,
strings.Join(permApplyTypes, ", "),
).WithParam("--type")
}
return token, docType, nil
}
func isPermApplyType(docType string) bool {
for _, allowed := range permApplyTypes {
if docType == allowed {
return true
}
}
return false
}
// DriveApplyPermission applies to the document owner for view or edit access
// on behalf of the invoking user. Matches the open-apis endpoint
// /open-apis/drive/v1/permissions/:token/members/apply.

View File

@@ -42,6 +42,7 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
wantType string
}{
{"docx", "https://example.feishu.cn/docx/doxTok123?from=share", "doxTok123", "docx"},
{"query path does not hijack docx url", "https://example.feishu.cn/docx/doxTok123?next=/wiki/wikTokBAD", "doxTok123", "docx"},
{"sheets", "https://example.feishu.cn/sheets/shtTok456?sheet=abc", "shtTok456", "sheet"},
{"base", "https://example.feishu.cn/base/bscTok789", "bscTok789", "bitable"},
{"bitable", "https://example.feishu.cn/bitable/bscTok789", "bscTok789", "bitable"},
@@ -86,6 +87,14 @@ func TestResolvePermApplyTarget_UnrecognizedURL(t *testing.T) {
}
}
func TestResolvePermApplyTarget_UnsupportedURLType(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget("https://example.feishu.cn/drive/folder/fldTok123", "")
if err == nil || !strings.Contains(err.Error(), "unsupported --type") {
t.Fatalf("expected unsupported type error, got: %v", err)
}
}
func TestResolvePermApplyTarget_Empty(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget(" ", "docx")

View File

@@ -0,0 +1,112 @@
// 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", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, 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", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "format", Default: "data", Enum: []string{"json", "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

@@ -1,5 +1,6 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
metadata:
requires:

View File

@@ -122,8 +122,6 @@ lark-cli docs +update --doc "<doc_id>" --command block_replace \
--content '<p>替换后的段落内容</p>'
```
投票 block 也使用 `block_replace` 做整块替换replacement 中的新 `<poll>` 会创建新的投票 block不继承旧投票的 block id、option id、票数、投票人、发布时间或当前用户选择如需把投票改成普通内容直接把 `--content` 写成目标 XML。
### block_delete — 删除指定 block
```bash

View File

@@ -9,7 +9,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|-|-|-|
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
| `<poll>` | 投票块,支持创建草稿、可选创建后发布 | `poll-type`, `is-anonymous`, `enable-due-time`, `due-time`, `publish-on-create` |
## 容器标签
|标签|说明|关键属性|
@@ -49,40 +48,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
- `<sub-page-list>``<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
## 投票 block
投票使用结构化 XML 表达。默认创建未发布草稿:空标题、两个空选项、单选、实名、无截止时间、结果策略固定为投票后可见。
```xml
<poll></poll>
```
带内容创建:
```xml
<poll poll-type="single" is-anonymous="false">
<poll-title>午饭吃什么?</poll-title>
<poll-option>米饭</poll-option>
<poll-option>面条</poll-option>
</poll>
```
创建后尝试发布:
```xml
<poll publish-on-create="true">
<poll-title>午饭吃什么?</poll-title>
<poll-option>米饭</poll-option>
<poll-option>面条</poll-option>
</poll>
```
公开可写属性只有:`poll-type="single|multiple"``is-anonymous="true|false"``enable-due-time="true|false"``due-time="毫秒时间戳"``publish-on-create="true|false"`。不要写入 `when-result-visible``option-id`、票数、投票人或当前用户投票状态。
读取已发布投票时XML 可能带只读结果字段,例如 `is-published``result-visible``user-count``poll-option count/percent/selected/voters-ref`。这些字段只用于展示,重新导入或 `block_replace` 时会被忽略;匿名投票不会通过 `reference_map` 暴露真实投票人。`voters-ref` 是读取详情的 opaque handle不是投票操作入口。
修改投票配置、替换已发布投票、把投票替换成普通内容,都使用 `block_replace`,语义是删除旧 block 并插入 replacement。新 `<poll>` 会创建新的投票 block不继承旧 block id、option id、票数、投票人、发布时间或当前用户选择。
# 四、块级复制与移动
## 移动block_move_after

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>` | `table` | 输出样式:`table` / `json` / `data` |
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |