Compare commits

...

16 Commits

Author SHA1 Message Date
chenhuang
b88039eeca fix: improve error message when auto-detected identity is unsupported
When a user-only shortcut is invoked without --as and auto-detect
resolves to bot (no login / expired token), show a clear message
suggesting `lark-cli auth login` instead of the misleading
"--as bot is not supported".

Change-Id: I684e6a422a907f428a68e8e4724fa8c50b13045d
Co-Authored-By: AI
2026-04-13 15:55:20 +08:00
Tsai_Hui
3917b77e91 feat: add drive create-shortcut shortcut (#432) 2026-04-13 11:54:31 +08:00
wangzhengkui
dc0d92708b fix(mail): restrict --output-dir to current working directory (#376)
* fix(mail): restrict --output-dir to current working directory

Previously, mail +watch --output-dir accepted absolute paths (e.g.
/etc, /tmp) and home directory paths (~/), allowing writes to arbitrary
locations. Since mail content is sender-controlled, this posed a risk
of writing attacker-influenced data to sensitive system directories.

Now all --output-dir values go through validate.SafeOutputPath which:
- Rejects absolute paths and ~ expansion
- Resolves .. and symlinks
- Enforces the result stays under CWD

* fix(mail): reject tilde paths in --output-dir explicitly

SafeOutputPath treats ~/x as a literal relative path, silently creating
a directory named "~" under CWD. Reject ~ prefixed paths with a clear
error message instead.

* fix(mail): reject all tilde-prefixed paths and use ErrValidation

- Broaden ~ check from "~ || ~/" to "~" prefix, covering ~user/path forms
- Use output.ErrValidation for consistent error type (exit code 2)

* fix(mail): add post-mkdir EvalSymlinks + CWD re-verification (TOCTOU)

SafeOutputPath validates before MkdirAll, but an attacker could replace
the newly created directory with a symlink between mkdir and the first
write. Add EvalSymlinks after MkdirAll and re-verify the resolved path
is still under CWD.

Also broaden ~ rejection to all tilde-prefixed paths (~user/path) and
use output.ErrValidation for consistent error types.

* fix(mail): use validate.SafeOutputPath for post-mkdir TOCTOU check

Replace direct os.Getwd and filepath.EvalSymlinks calls with a second
SafeOutputPath call after MkdirAll. This satisfies the forbidigo lint
rule (no direct os/filepath calls in shortcuts/) while maintaining the
same TOCTOU protection.

* fix(mail): use original relative path for post-mkdir re-validation

SafeOutputPath rejects absolute paths, but after the first call
outputDir was already resolved to an absolute path. Pass the original
relative path to the second SafeOutputPath call so it can properly
re-validate after MkdirAll.

* fix(mail): remove redundant post-mkdir SafeOutputPath call

The second SafeOutputPath call after MkdirAll provided no real TOCTOU
protection: mail +watch is long-running, so the directory could be
replaced at any point during the session, not just between mkdir and
the check. The first SafeOutputPath already validates and resolves
the path; one call is sufficient.
2026-04-13 10:53:08 +08:00
Yuxuan Zhao
085ffd87f3 feat: add stable cli e2e tests (#401)
* feat: add stable bot-only cli e2e subset

Change-Id: I62edf59d179e407954f65f82e94cf5dcf4938080

* fix: address review comments on stable cli e2e tests

Change-Id: I4436100c30adf2694cd06953961f8d77f576fc1e

* fix: reduce flakiness in drive and im e2e helpers

Change-Id: I51e77d857f1fd9aec5ee34adf5045cc695239f21

* fix: document missing drive cleanup support

Change-Id: I3d4f034145bd69fb7640e707fcda05146b8754c7

* style: unify e2e cleanup comments

Change-Id: I40d906c9168754ad71ef9fb770ff4c340fc19beb

* test: update e2e assertions

Change-Id: I73c21b4b38d4ced7ea27cb327075957ec2b9a2a2

* test: stabilize cli e2e bot-only coverage

Change-Id: Ied897c37c4f42e446d55d110461aa34ae198195d
2026-04-12 16:52:41 +08:00
zero-my
f6b8091843 Feat/task section updates (#430)
* docs(task): document sections API resources and add URL parsing reminder

* feat(task): support --section-guid flag in tasklist-task-add shortcut

* docs(task): document sections API resources, permissions, and URL parsing
2026-04-12 16:12:16 +08:00
OwenYWT
0e7f507efb docs(lark-doc): clarify when markdown escaping is needed (#312)
* docs(lark-doc): clarify when markdown escaping is needed

* docs(lark-doc): fix escaped special character code span
2026-04-11 23:56:07 +08:00
liangshuo-1
1ff2dc578e chore: bump version to v1.0.9 and update changelog (#426)
Change-Id: I570d2f33d08c94d6df8daf78801be1bbcd252c3e
2026-04-11 22:18:31 +08:00
vanilla
69ae326d01 feat: add attendance user_task.query (#405)
Change-Id: Ie34b9b98859942ff368a9808fc2efab4d2bf27fa
2026-04-11 21:55:05 +08:00
ViperCai
e07842d3b5 feat(slides): return presentation URL in slides +create output (#425)
After creating the presentation, call drive batch_query (with_url=true)
to fetch the document URL and include it in the output. The fetch is
best-effort so it won't break creation if the API call fails.

Also update the skill reference doc to document the new optional url
return field.
2026-04-11 21:19:31 +08:00
ethan-zhx
a9c07cebb6 feat(slides): add slides +create shortcut with --slides one-step creation (#389)
Co-authored-by: caichengjie.viper <caichengjie.viper@bytedance.com>
2026-04-11 18:37:11 +08:00
caojie0621
f6a31e0853 feat(sheets): add dimension shortcuts for row/column operations (#413)
Add 5 new sheet shortcuts for row/column management:
- +add-dimension: append rows/columns at the end
- +insert-dimension: insert rows/columns at a position
- +update-dimension: update visibility and size
- +move-dimension: move rows/columns to a new position
- +delete-dimension: delete rows/columns

Includes unit tests (89-100% coverage) and skill reference docs.
2026-04-11 17:21:21 +08:00
liujinkun2025
bd5a33c0b7 feat(drive): add drive folder delete shortcut with async task polling (#415)
Change-Id: Ifb34f67296b800501a1b4960e02d5fed3382b84a
2026-04-11 16:47:03 +08:00
caojie0621
3242ca6f7f feat(sheets): add cell operation shortcuts for merge, replace, and style (#412)
Add 5 new sheet shortcuts for cell operations:
- +merge-cells: merge cells with MERGE_ALL/MERGE_ROWS/MERGE_COLUMNS
- +unmerge-cells: split merged cells
- +replace: find and replace cell values
- +set-style: set cell style (font, color, alignment, border)
- +batch-set-style: batch set styles for multiple ranges

Includes unit tests (81-89% coverage) and skill reference docs.
2026-04-11 16:45:14 +08:00
caojie0621
368ec7e753 docs(drive): add guide for granting document permission to current bot (#414) 2026-04-11 13:13:29 +08:00
liangshuo-1
9f81e7e567 feat: add RuntimeContext.BotInfo() for lazy bot identity retrieval (#409)
Add BotInfo() method on RuntimeContext that lazily fetches the current
app's bot open_id and display name from /bot/v3/info on first call,
cached via sync.OnceValues for the lifetime of the process.

- BotInfo struct (OpenID, AppName) in Identity section of runner.go
- fetchBotInfo() uses DoAPIAsBot for consistent header injection
- CanBot() on CliConfig gates the call when bot identity is unavailable
- Nil guard prevents panic in test contexts
- Full test coverage via httpmock.Registry + mounted shortcuts

Change-Id: I40ac710fb52d13939853f71827a5cbdbddd4f80f
2026-04-11 11:53:02 +08:00
zhicong666-bytedance
a00dfad56a feat: support minutes search (#359)
* feat: support minutes search by keyword and owner

* fix(minutes): align search output fields and clarify same-day queries

* fix(minutes): tighten search validation and output

* docs(vc): clarify recording usage examples

* test(minutes): remove redundant loop variable copies

* test(minutes): add docstrings for search tests

* refine minutes search params and skill routing

* minutes: refine search params payload and dry-run params feed

* skills: fix minutes search reference wording and vc link

* fix(minutes): align page-size cap to 30 and update tests

* skills: route meeting minutes lookup via vc first

* docs(skills): require shortcut reference reads
2026-04-11 06:31:10 +08:00
109 changed files with 13822 additions and 206 deletions

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [v1.0.9] - 2026-04-11
### Features
- Add attendance `user_task.query` (#405)
- Support minutes search (#359)
- **slides**: Add slides `+create` shortcut with `--slides` one-step creation (#389)
- **slides**: Return presentation URL in slides `+create` output (#425)
- **sheets**: Add dimension shortcuts for row/column operations (#413)
- **sheets**: Add cell operation shortcuts for merge, replace, and style (#412)
- **drive**: Add drive folder delete shortcut with async task polling (#415)
### Documentation
- **drive**: Add guide for granting document permission to current bot (#414)
## [v1.0.8] - 2026-04-10
### Features
@@ -287,6 +303,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 21 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 21 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 13 business domains, 200+ curated commands, 21 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -30,6 +30,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
@@ -136,6 +137,7 @@ lark-cli auth status
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 21 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 21 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 13 大业务域、200+ 精选命令、21 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -30,6 +30,7 @@
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
@@ -137,6 +138,7 @@ lark-cli auth status
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |

View File

@@ -127,8 +127,8 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
list := strings.Join(supported, ", ")
if f.IdentityAutoDetected {
return output.ErrValidation(
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s",
as, list, supported[0])
"this command requires %s identity, but no user login was found (auto-detected as %s)\nhint: run `lark-cli auth login` first, then retry",
list, as)
}
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
}

View File

@@ -174,15 +174,15 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = true
err := f.CheckIdentity(core.AsUser, []string{"bot"})
err := f.CheckIdentity(core.AsBot, []string{"user"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "resolved identity") {
t.Errorf("expected 'resolved identity' in error, got: %v", err)
if !strings.Contains(err.Error(), "requires user identity") {
t.Errorf("expected 'requires user identity' in error, got: %v", err)
}
if !strings.Contains(err.Error(), "hint: use --as bot") {
t.Errorf("expected hint in error, got: %v", err)
if !strings.Contains(err.Error(), "auth login") {
t.Errorf("expected 'auth login' hint in error, got: %v", err)
}
}

View File

@@ -163,6 +163,16 @@ type CliConfig struct {
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
// Must match extension/credential.SupportsBot.
const identityBotBit uint8 = 1 << 1
// CanBot reports whether the current credential context supports bot identity.
// Returns true when SupportedIdentities is unset (0, unknown) or includes the bot bit.
func (c *CliConfig) CanBot() bool {
return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0
}
// GetConfigDir returns the config directory path.
// If the home directory cannot be determined, it falls back to a relative path
// and prints a warning to stderr.

View File

@@ -187,3 +187,24 @@ func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
}
}
func TestCliConfig_CanBot(t *testing.T) {
tests := []struct {
name string
supportedIdentities uint8
want bool
}{
{"unset (0) defaults to true", 0, true},
{"user only", 1, false},
{"bot only", 2, true},
{"both", 3, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &CliConfig{SupportedIdentities: tt.supportedIdentities}
if got := cfg.CanBot(); got != tt.want {
t.Errorf("CanBot() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -33,6 +33,11 @@ const (
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -60,6 +65,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
// rate limit
case LarkErrRateLimit:
return ExitAPI, "rate_limit", "please try again later"
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
}
return ExitAPI, "api_error", ""

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"strings"
"testing"
)
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
wantExitCode int
wantType string
wantHint string
}{
{
name: "resource contention",
code: LarkErrDriveResourceContention,
wantExitCode: ExitAPI,
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
},
{
name: "cross tenant unit",
code: LarkErrDriveCrossTenantUnit,
wantExitCode: ExitAPI,
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
},
{
name: "cross brand",
code: LarkErrDriveCrossBrand,
wantExitCode: ExitAPI,
wantType: "cross_brand",
wantHint: "same brand environment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
if gotExitCode != tt.wantExitCode {
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
}
if gotType != tt.wantType {
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
}
if gotHint == "" {
t.Fatal("expected non-empty hint")
}
if !strings.Contains(gotHint, tt.wantHint) {
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
}
})
}
}

View File

@@ -43,6 +43,10 @@
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
"zh": { "title": "电子表格", "description": "电子表格操作" }
},
"slides": {
"en": { "title": "Slides", "description": "Create and manage presentations, read content, and add or remove slides" },
"zh": { "title": "幻灯片", "description": "创建和管理演示文稿、读取内容,以及新增或删除幻灯片页面" }
},
"task": {
"en": { "title": "Task", "description": "Task, task list, and subtask management" },
"zh": { "title": "任务", "description": "任务、清单、子任务管理" }

View File

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

View File

@@ -120,6 +120,8 @@ func permissionTargetLabel(resourceType string) string {
return "spreadsheet"
case "bitable", "base":
return "base"
case "slides":
return "presentation"
case "file":
return "file"
case "folder":

View File

@@ -42,6 +42,7 @@ type RuntimeContext struct {
resolvedAs core.Identity // effective identity resolved by framework
Factory *cmdutil.Factory // injected by framework
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
}
@@ -71,6 +72,57 @@ func (ctx *RuntimeContext) IsBot() bool {
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
type BotInfo struct {
OpenID string
AppName string
}
// BotInfo returns the bot's open_id and display name, fetched lazily from /bot/v3/info.
// Unlike UserOpenId() (which reads from config), this requires a network call and may fail.
// Thread-safe via sync.OnceValues; the API is called at most once per RuntimeContext.
func (ctx *RuntimeContext) BotInfo() (*BotInfo, error) {
if ctx.botInfoFunc == nil {
return nil, fmt.Errorf("BotInfo not available (runtime context not fully initialized)")
}
return ctx.botInfoFunc()
}
// fetchBotInfo calls /bot/v3/info using bot identity and parses the response.
func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
if !ctx.Config.CanBot() {
return nil, fmt.Errorf("fetch bot info: bot identity is not available in current credential context")
}
resp, err := ctx.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/bot/v3/info",
})
if err != nil {
return nil, fmt.Errorf("fetch bot info: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
}
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
OpenID string `json:"open_id"`
AppName string `json:"app_name"`
} `json:"data"`
}
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
}
if envelope.Code != 0 {
return nil, fmt.Errorf("fetch bot info: [%d] %s", envelope.Code, envelope.Msg)
}
if envelope.Data.OpenID == "" {
return nil, fmt.Errorf("fetch bot info: open_id is empty")
}
return &BotInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
}
// Ctx returns the context.Context propagated from cmd.Context().
func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx }
@@ -639,6 +691,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
return f.NewAPIClientWithConfig(config)
})
rctx.botInfoFunc = sync.OnceValues(rctx.fetchBotInfo)
sdk, err := f.LarkClient()
if err != nil {

View File

@@ -0,0 +1,297 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
// botInfoTestConfig returns a CliConfig suitable for bot info tests.
func botInfoTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
}
// runBotInfoShortcut mounts a shortcut that calls BotInfo() and executes it.
// The shortcut stores the result (or error) in the provided pointers.
func runBotInfoShortcut(t *testing.T, f *cmdutil.Factory, gotInfo **BotInfo, gotErr *error) {
t.Helper()
s := Shortcut{
Service: "test",
Command: "+bot-info",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
info, err := rctx.BotInfo()
*gotInfo = info
*gotErr = err
return nil
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+bot-info", "--as", "bot"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("shortcut execution failed: %v", err)
}
}
func TestFetchBotInfo_Success(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_bot_abc123",
"app_name": "TestBot",
},
},
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.OpenID != "ou_bot_abc123" {
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_abc123")
}
if info.AppName != "TestBot" {
t.Errorf("AppName = %q, want %q", info.AppName, "TestBot")
}
}
func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_bot_header",
"app_name": "HeaderBot",
},
},
}
reg.Register(stub)
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify shortcut context headers were injected
if stub.CapturedHeaders.Get("X-Cli-Shortcut") == "" {
t.Error("missing X-Cli-Shortcut header on /bot/v3/info request")
}
if stub.CapturedHeaders.Get("X-Cli-Execution-Id") == "" {
t.Error("missing X-Cli-Execution-Id header on /bot/v3/info request")
}
}
func TestFetchBotInfo_OnceSemantics(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
// Only register one stub — if fetchBotInfo is called twice, the second call
// would fail with "no stub" since the first stub is already matched.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_bot_once",
"app_name": "OnceBot",
},
},
})
s := Shortcut{
Service: "test",
Command: "+bot-info-once",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
// Call BotInfo twice — second should use cached result
_, _ = rctx.BotInfo()
info, err := rctx.BotInfo()
if err != nil {
t.Errorf("second BotInfo() call failed: %v", err)
}
if info.OpenID != "ou_bot_once" {
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_once")
}
return nil
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+bot-info-once", "--as", "bot"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("shortcut execution failed: %v", err)
}
}
func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 99991,
"msg": "no permission",
},
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err == nil {
t.Fatal("expected error for non-zero code")
}
if !strings.Contains(err.Error(), "[99991]") {
t.Errorf("error = %q, want substring [99991]", err.Error())
}
}
func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"open_id": "",
"app_name": "EmptyBot",
},
},
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err == nil {
t.Fatal("expected error for empty open_id")
}
if !strings.Contains(err.Error(), "open_id is empty") {
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
}
}
func TestFetchBotInfo_HTTP4xx(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Status: 403,
Body: map[string]interface{}{"code": 403, "msg": "forbidden"},
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err == nil {
t.Fatal("expected error for HTTP 403")
}
if !strings.Contains(err.Error(), "403") {
t.Errorf("error = %q, want substring '403'", err.Error())
}
}
func TestFetchBotInfo_InvalidJSON(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
RawBody: []byte("not json"),
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
// Error may come from SDK-level parse or our unmarshal wrapper
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
t.Errorf("error = %q, want JSON parse failure", err.Error())
}
}
func TestFetchBotInfo_CanBotFalse(t *testing.T) {
cfg := botInfoTestConfig(t)
cfg.SupportedIdentities = 1 // user only
f, _, _, _ := cmdutil.TestFactory(t, cfg)
// Use a dual-auth shortcut running as user, calling BotInfo() internally.
// No /bot/v3/info stub — CanBot should short-circuit before API call.
var info *BotInfo
var err error
s := Shortcut{
Service: "test",
Command: "+bot-info-canbot",
AuthTypes: []string{"user", "bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
i, e := rctx.BotInfo()
info = i
err = e
return nil
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+bot-info-canbot", "--as", "user"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if execErr := parent.Execute(); execErr != nil {
t.Fatalf("shortcut execution failed: %v", execErr)
}
if err == nil {
t.Fatal("expected error when bot identity not available")
}
if info != nil {
t.Errorf("expected nil info, got %+v", info)
}
if !strings.Contains(err.Error(), "not available") {
t.Errorf("error = %q, want substring 'not available'", err.Error())
}
}
func TestBotInfo_NilFunc(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
rctx := TestNewRuntimeContext(cmd, &core.CliConfig{})
_, err := rctx.BotInfo()
if err == nil {
t.Fatal("expected error for nil botInfoFunc")
}
if !strings.Contains(err.Error(), "not fully initialized") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -5,6 +5,7 @@ package common
import (
"context"
"sync"
"github.com/spf13/cobra"
@@ -27,3 +28,12 @@ func TestNewRuntimeContextWithCtx(ctx context.Context, cmd *cobra.Command, cfg *
func TestNewRuntimeContextWithIdentity(cmd *cobra.Command, cfg *core.CliConfig, as core.Identity) *RuntimeContext {
return &RuntimeContext{Cmd: cmd, Config: cfg, resolvedAs: as}
}
// TestNewRuntimeContextWithBotInfo creates a RuntimeContext with a pre-set BotInfo for testing.
func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, info *BotInfo) *RuntimeContext {
rctx := &RuntimeContext{Cmd: cmd, Config: cfg}
rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) {
return info, nil
})
return rctx
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveCreateShortcutAllowedTypes = map[string]bool{
"file": true,
"docx": true,
"bitable": true,
"doc": true,
"sheet": true,
"mindnote": true,
"slides": true,
}
type driveCreateShortcutSpec struct {
FileToken string
FileType string
FolderToken string
}
func newDriveCreateShortcutSpec(runtime *common.RuntimeContext) driveCreateShortcutSpec {
return driveCreateShortcutSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FileType: strings.ToLower(strings.TrimSpace(runtime.Str("type"))),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
}
}
// RequestBody builds the create_shortcut API payload from the shortcut spec.
func (s driveCreateShortcutSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"parent_token": s.FolderToken,
"refer_entity": map[string]interface{}{
"refer_token": s.FileToken,
"refer_type": s.FileType,
},
}
}
// DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
var DriveCreateShortcut = common.Shortcut{
Service: "drive",
Command: "+create-shortcut",
Description: "Create a Drive shortcut in another folder",
Risk: "write",
Scopes: []string{"space:document:shortcut"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "source file token to reference", Required: true},
{Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newDriveCreateShortcutSpec(runtime)
return common.NewDryRunAPI().
Desc("Create a Drive shortcut").
POST("/open-apis/drive/v1/files/create_shortcut").
Desc("[1] Create shortcut").
Body(spec.RequestBody())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newDriveCreateShortcutSpec(runtime)
fmt.Fprintf(
runtime.IO().ErrOut,
"Creating shortcut for %s %s in folder %s...\n",
spec.FileType,
common.MaskToken(spec.FileToken),
common.MaskToken(spec.FolderToken),
)
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_shortcut",
nil,
spec.RequestBody(),
)
if err != nil {
return err
}
out := map[string]interface{}{
"created": true,
"source_file_token": spec.FileToken,
"source_type": spec.FileType,
"folder_token": spec.FolderToken,
}
if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" {
out["shortcut_token"] = shortcutToken
}
if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" {
out["url"] = url
}
if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" {
out["title"] = title
}
runtime.Out(out, nil)
return nil
},
}
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
}
if spec.FileType == "folder" {
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
}
if !driveCreateShortcutAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
}
return nil
}

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes verifies unsupported source types are rejected early.
func TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec driveCreateShortcutSpec
wantErr string
}{
{
name: "wiki",
spec: driveCreateShortcutSpec{
FileToken: "wiki_token_test",
FileType: "wiki",
FolderToken: "target_folder_token_test",
},
wantErr: "underlying file token first",
},
{
name: "folder",
spec: driveCreateShortcutSpec{
FileToken: "folder_token_test",
FileType: "folder",
FolderToken: "target_folder_token_test",
},
wantErr: "not folders",
},
{
name: "shortcut",
spec: driveCreateShortcutSpec{
FileToken: "shortcut_token_test",
FileType: "shortcut",
FolderToken: "target_folder_token_test",
},
wantErr: "Supported types",
},
{
name: "missing folder token",
spec: driveCreateShortcutSpec{
FileToken: "file_token_test",
FileType: "docx",
},
wantErr: "--folder-token must not be empty",
},
{
name: "unknown",
spec: driveCreateShortcutSpec{
FileToken: "file_token_test",
FileType: "unknown",
FolderToken: "target_folder_token_test",
},
wantErr: "Supported types",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveCreateShortcutSpec(tt.spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}
// TestDriveCreateShortcutDryRunIncludesSingleCreateRequest verifies dry-run only previews the create request.
func TestDriveCreateShortcutDryRunIncludesSingleCreateRequest(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-shortcut"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", " doc_token_test "); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", " folder_target_token_test "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveCreateShortcut.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Method != "POST" {
t.Fatalf("first method = %q, want POST", got.API[0].Method)
}
if got.API[0].Body["parent_token"] != "folder_target_token_test" {
t.Fatalf("parent_token = %#v, want folder_target_token_test", got.API[0].Body["parent_token"])
}
referEntity, _ := got.API[0].Body["refer_entity"].(map[string]interface{})
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
t.Fatalf("unexpected refer_entity: %#v", referEntity)
}
}
// TestDriveCreateShortcutUsesProvidedFolderToken verifies execution uses the explicit target folder token.
func TestDriveCreateShortcutUsesProvidedFolderToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_shortcut",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"succ_shortcut_node": map[string]interface{}{
"token": "shortcut_token_test",
"name": "shortcut_name_test",
"type": "docx",
"parent_token": "folder_target_token_test",
"url": "https://example.feishu.cn/docx/shortcut_token_test",
"shortcut_info": map[string]interface{}{
"target_type": "docx",
"target_token": "doc_token_test",
},
},
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
"+create-shortcut",
"--file-token", " doc_token_test ",
"--type", " DOCX ",
"--folder-token", " folder_target_token_test ",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedJSONBody(t, createStub)
if body["parent_token"] != "folder_target_token_test" {
t.Fatalf("parent_token = %#v, want folder_target_token_test", body["parent_token"])
}
referEntity, _ := body["refer_entity"].(map[string]interface{})
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
t.Fatalf("unexpected refer_entity: %#v", referEntity)
}
data := decodeDriveEnvelope(t, stdout)
if data["shortcut_token"] != "shortcut_token_test" {
t.Fatalf("shortcut_token = %#v, want shortcut_token_test", data["shortcut_token"])
}
if data["folder_token"] != "folder_target_token_test" {
t.Fatalf("folder_token = %#v, want folder_target_token_test", data["folder_token"])
}
if data["source_file_token"] != "doc_token_test" {
t.Fatalf("source_file_token = %#v, want doc_token_test", data["source_file_token"])
}
if data["title"] != "shortcut_name_test" {
t.Fatalf("title = %#v, want shortcut_name_test", data["title"])
}
if data["url"] != "https://example.feishu.cn/docx/shortcut_token_test" {
t.Fatalf("url = %#v, want https://example.feishu.cn/docx/shortcut_token_test", data["url"])
}
if data["created"] != true {
t.Fatalf("created = %#v, want true", data["created"])
}
}
// TestDriveCreateShortcutValidateRequiresFolderToken verifies folder-token is mandatory.
func TestDriveCreateShortcutValidateRequiresFolderToken(t *testing.T) {
err := validateDriveCreateShortcutSpec(driveCreateShortcutSpec{
FileToken: "doc_token_test",
FileType: "docx",
})
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken verifies runtime normalization rejects blank folder tokens.
func TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-shortcut"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", "doc_token_test"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", " "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
err := DriveCreateShortcut.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestDriveCreateShortcutClassifiesKnownAPIConstraints verifies known API constraints surface as structured errors.
func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
msg string
wantType string
wantHint string
wantMsgPart string
}{
{
name: "resource contention",
code: output.LarkErrDriveResourceContention,
msg: "resource contention occurred, please retry",
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
wantMsgPart: "resource contention occurred",
},
{
name: "cross tenant and unit",
code: output.LarkErrDriveCrossTenantUnit,
msg: "cross tenant and unit not support",
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
wantMsgPart: "cross tenant and unit not support",
},
{
name: "cross brand",
code: output.LarkErrDriveCrossBrand,
msg: "cross brand not support",
wantType: "cross_brand",
wantHint: "same brand environment",
wantMsgPart: "cross brand not support",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_shortcut",
Body: map[string]interface{}{
"code": float64(tt.code),
"msg": tt.msg,
},
})
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
"+create-shortcut",
"--file-token", "doc_token_test",
"--type", "docx",
"--folder-token", "folder_token_test",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail.Type != tt.wantType {
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
}
if exitErr.Detail.Code != tt.code {
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
}
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
}
})
}
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveDeleteAllowedTypes = map[string]bool{
"file": true,
"docx": true,
"bitable": true,
"doc": true,
"sheet": true,
"mindnote": true,
"folder": true,
"shortcut": true,
"slides": true,
}
// driveDeleteSpec contains the normalized input needed to issue a delete
// request against the Drive files endpoint.
type driveDeleteSpec struct {
FileToken string
FileType string
}
// DriveDelete deletes a Drive file or folder and handles the async task
// polling required by folder deletes.
var DriveDelete = common.Shortcut{
Service: "drive",
Command: "+delete",
Description: "Delete a file or folder in Drive",
Risk: "high-risk-write",
Scopes: []string{"space:document:delete"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveDeleteSpec(driveDeleteSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveDeleteSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
}
dry := common.NewDryRunAPI().
Desc("Delete file or folder in Drive")
dry.DELETE("/open-apis/drive/v1/files/:file_token").
Desc("[1] Delete file/folder").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"type": spec.FileType})
if spec.FileType == "folder" {
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async task status (for folder delete)").
Params(driveTaskCheckParams("<task_id>"))
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveDeleteSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
}
fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken))
data, err := runtime.CallAPI(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
map[string]interface{}{"type": spec.FileType},
nil,
)
if err != nil {
return err
}
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
"file_token": spec.FileToken,
"type": spec.FileType,
"ready": ready,
}
if ready {
out["deleted"] = true
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
return nil
}
runtime.Out(map[string]interface{}{
"deleted": true,
"file_token": spec.FileToken,
"type": spec.FileType,
}, nil)
return nil
},
}
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported")
}
if !driveDeleteAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType)
}
return nil
}

View File

@@ -0,0 +1,224 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
t.Parallel()
err := validateDriveDeleteSpec(driveDeleteSpec{
FileToken: "wiki_token_test",
FileType: "wiki",
})
if err == nil {
t.Fatal("expected wiki type error, got nil")
}
if !strings.Contains(err.Error(), "wiki documents are not supported") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +delete"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", "folder"); err != nil {
t.Fatalf("set --type: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveDelete.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[0].Method != "DELETE" {
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
}
if got.API[0].Params["type"] != "folder" {
t.Fatalf("delete params = %#v", got.API[0].Params)
}
if got.API[1].Params["task_id"] != "<task_id>" {
t.Fatalf("task check params = %#v", got.API[1].Params)
}
}
func TestDriveDeleteRequiresYes(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "file_token_test",
"--type", "file",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveDeleteFileSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/file_token_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "file_token_test",
"--type", "file",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
t.Fatalf("stdout missing file token: %s", stdout.String())
}
}
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
tests := []struct {
name string
taskCheckBody map[string]interface{}
wantErrContains string
wantStdout []string
}{
{
name: "success",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
wantStdout: []string{
`"task_id": "task_123"`,
`"deleted": true`,
`"ready": true`,
},
},
{
name: "timeout",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
},
wantStdout: []string{
`"ready": false`,
`"timed_out": true`,
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
},
},
{
name: "failed",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "fail"},
},
wantErrContains: "folder task failed",
},
{
name: "task_check error",
taskCheckBody: map[string]interface{}{
"code": 1061001,
"msg": "internal error",
},
wantErrContains: "internal error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fld_src",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: tt.taskCheckBody,
})
withSingleDriveTaskCheckPoll(t)
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "fld_src",
"--type", "folder",
"--yes",
"--as", "bot",
}, f, stdout)
if tt.wantErrContains != "" {
if err == nil {
t.Fatal("expected delete failure, got nil")
}
if !strings.Contains(err.Error(), tt.wantErrContains) {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, needle := range tt.wantStdout {
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
}
}
})
}
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
@@ -18,6 +19,8 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
var driveTaskCheckPollMu sync.Mutex
func driveTestConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -37,6 +40,18 @@ func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil
return parent.Execute()
}
func withSingleDriveTaskCheckPoll(t *testing.T) {
t.Helper()
driveTaskCheckPollMu.Lock()
prevAttempts, prevInterval := driveTaskCheckPollAttempts, driveTaskCheckPollInterval
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = 1, 0
t.Cleanup(func() {
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = prevAttempts, prevInterval
driveTaskCheckPollMu.Unlock()
})
}
func withDriveWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()

View File

@@ -115,7 +115,7 @@ var DriveMove = common.Shortcut{
"ready": ready,
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID)
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand

View File

@@ -14,8 +14,8 @@ import (
)
var (
driveMovePollAttempts = 30
driveMovePollInterval = 2 * time.Second
driveTaskCheckPollAttempts = 30
driveTaskCheckPollInterval = 2 * time.Second
)
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
}
// driveTaskCheckStatus represents the status payload returned by
// /drive/v1/files/task_check for async folder operations.
// /drive/v1/files/task_check for async folder move/delete operations.
type driveTaskCheckStatus struct {
TaskID string
Status string
@@ -72,7 +72,11 @@ func (s driveTaskCheckStatus) Ready() bool {
}
func (s driveTaskCheckStatus) Failed() bool {
return strings.EqualFold(strings.TrimSpace(s.Status), "failed")
status := strings.TrimSpace(s.Status)
// The shared task_check endpoint is reused by multiple async flows. Some
// backends return "failed", while folder delete can return the shorter
// terminal state "fail".
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
}
func (s driveTaskCheckStatus) Pending() bool {
@@ -91,8 +95,8 @@ func (s driveTaskCheckStatus) StatusLabel() string {
// driveTaskCheckResultCommand prints the resume command shown when bounded
// polling ends before the backend task completes.
func driveTaskCheckResultCommand(taskID string) string {
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID)
func driveTaskCheckResultCommand(taskID, as string) string {
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s --as %s", taskID, as)
}
// driveTaskCheckParams keeps the task_check query parameter shape in one place
@@ -130,31 +134,42 @@ func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) drive
}
}
// pollDriveTaskCheck polls the backend for a bounded period and returns the
// last seen status so callers can emit a follow-up command when needed.
// pollDriveTaskCheck polls the shared task_check endpoint for a bounded period
// and returns the last seen status so callers can emit a follow-up command
// when needed.
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
lastStatus := driveTaskCheckStatus{TaskID: taskID}
for attempt := 1; attempt <= driveMovePollAttempts; attempt++ {
var (
seenStatus bool
lastErr error
)
for attempt := 1; attempt <= driveTaskCheckPollAttempts; attempt++ {
if attempt > 1 {
time.Sleep(driveMovePollInterval)
time.Sleep(driveTaskCheckPollInterval)
}
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
continue
}
seenStatus = true
lastStatus = status
// Success and failure are terminal backend states. Any other value is kept
// as pending so the caller can decide whether to continue or resume later.
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Folder move completed successfully.\n")
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed")
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed")
}
}
if !seenStatus && lastErr != nil {
return driveTaskCheckStatus{}, false, lastErr
}
return lastStatus, false, nil
}

View File

@@ -102,91 +102,91 @@ func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
}
}
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
func TestDriveMoveFolderTaskCheckOutcomes(t *testing.T) {
tests := []struct {
name string
taskCheckBody map[string]interface{}
wantErrContains string
wantStdout []string
}{
{
name: "success",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
wantStdout: []string{
`"task_id": "task_123"`,
`"ready": true`,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
{
name: "timeout",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "pending"},
},
wantStdout: []string{
`"ready": false`,
`"timed_out": true`,
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
},
},
{
name: "all polls fail",
taskCheckBody: map[string]interface{}{
"code": 1061001,
"msg": "internal error",
},
wantErrContains: "internal error",
},
})
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
driveMovePollAttempts, driveMovePollInterval = 1, 0
t.Cleanup(func() {
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "fld_src",
"--type", "folder",
"--folder-token", "fld_dst",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) {
t.Fatalf("stdout missing task id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("stdout missing ready=true: %s", stdout.String())
}
}
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "pending"},
},
})
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
driveMovePollAttempts, driveMovePollInterval = 1, 0
t.Cleanup(func() {
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "fld_src",
"--type", "folder",
"--folder-token", "fld_dst",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) {
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: tt.taskCheckBody,
})
withSingleDriveTaskCheckPoll(t)
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "fld_src",
"--type", "folder",
"--folder-token", "fld_dst",
"--as", "bot",
}, f, stdout)
if tt.wantErrContains != "" {
if err == nil {
t.Fatal("expected task_check polling error, got nil")
}
if !bytes.Contains([]byte(err.Error()), []byte(tt.wantErrContains)) {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, needle := range tt.wantStdout {
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
}
}
})
}
}

View File

@@ -246,3 +246,34 @@ func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
t.Fatalf("stdout missing failed=false: %s", stdout.String())
}
}
func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "fail"},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "task_check",
"--task-id", "task_123",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"status": "fail"`)) {
t.Fatalf("stdout missing fail status: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": true`)) {
t.Fatalf("stdout missing failed=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
}

View File

@@ -9,12 +9,14 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
DriveUpload,
DriveCreateShortcut,
DriveDownload,
DriveAddComment,
DriveExport,
DriveExportDownload,
DriveImport,
DriveMove,
DriveDelete,
DriveTaskResult,
}
}

View File

@@ -5,18 +5,21 @@ package drive
import "testing"
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{
"+upload",
"+create-shortcut",
"+download",
"+add-comment",
"+export",
"+export-download",
"+import",
"+move",
"+delete",
"+task_result",
}

View File

@@ -192,36 +192,23 @@ var MailWatch = common.Shortcut{
msgFormat := runtime.Str("msg-format")
outputDir := runtime.Str("output-dir")
if outputDir != "" {
if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
home, err := vfs.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot expand ~: %w", err)
}
if outputDir == "~" {
outputDir = home
} else {
outputDir = filepath.Join(home, outputDir[2:])
}
} else if filepath.IsAbs(outputDir) {
outputDir = filepath.Clean(outputDir)
} else {
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
// literal relative path (creating a directory named "~"), which is
// confusing. This also covers ~user/path forms.
if strings.HasPrefix(outputDir, "~") {
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
}
// Resolve symlinks on the output directory so all writes use the real
// filesystem path. This prevents a symlink from redirecting writes to
// an unintended location (TOCTOU mitigation).
// Enforce CWD containment: reject absolute paths, path traversal,
// and symlink escapes. SafeOutputPath returns a resolved absolute path
// under CWD, preventing writes to arbitrary system directories.
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
}
resolved, err := filepath.EvalSymlinks(outputDir)
if err != nil {
return fmt.Errorf("cannot resolve output directory: %w", err)
}
outputDir = resolved
}
labelIDsInput := runtime.Str("label-ids")
folderIDsInput := runtime.Str("folder-ids")

View File

@@ -0,0 +1,347 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultMinutesSearchPageSize = 15
maxMinutesSearchPageSize = 30
maxMinutesSearchQueryLen = 50
)
// parseTimeRange normalizes --start and --end into RFC3339 timestamps.
func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
start := strings.TrimSpace(runtime.Str("start"))
end := strings.TrimSpace(runtime.Str("end"))
if start == "" && end == "" {
return "", "", nil
}
var startTime, endTime string
if start != "" {
parsed, err := toRFC3339(start)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
}
startTime = parsed
}
if end != "" {
parsed, err := toRFC3339(end, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
}
endTime = parsed
}
if startTime != "" && endTime != "" {
st, err := time.Parse(time.RFC3339, startTime)
if err != nil {
return "", "", fmt.Errorf("parse normalized --start: %w", err)
}
et, err := time.Parse(time.RFC3339, endTime)
if err != nil {
return "", "", fmt.Errorf("parse normalized --end: %w", err)
}
if st.After(et) {
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
}
}
return startTime, endTime, nil
}
// toRFC3339 converts a supported CLI time input into an RFC3339 timestamp.
func toRFC3339(input string, hint ...string) (string, error) {
ts, err := common.ParseTime(input, hint...)
if err != nil {
return "", err
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
}
return time.Unix(sec, 0).Format(time.RFC3339), nil
}
// resolveUserIDs expands special user identifiers and removes duplicates.
func resolveUserIDs(flagName string, ids []string, runtime *common.RuntimeContext) ([]string, error) {
if len(ids) == 0 {
return nil, nil
}
currentUserID := runtime.UserOpenId()
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, id := range ids {
if strings.EqualFold(id, "me") {
if currentUserID == "" {
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
}
id = currentUserID
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
// buildTimeFilter builds the create_time filter block for the API request.
func buildTimeFilter(startTime, endTime string) map[string]interface{} {
if startTime == "" && endTime == "" {
return nil
}
timeRange := map[string]interface{}{}
if startTime != "" {
timeRange["start_time"] = startTime
}
if endTime != "" {
timeRange["end_time"] = endTime
}
return timeRange
}
// buildMinutesSearchFilter builds the filter object for the API request body.
func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
filter := map[string]interface{}{}
ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
if err != nil {
return nil, err
}
if len(ownerIDs) > 0 {
filter["owner_ids"] = ownerIDs
}
participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
if err != nil {
return nil, err
}
if len(participantIDs) > 0 {
filter["participant_ids"] = participantIDs
}
if timeRange := buildTimeFilter(startTime, endTime); timeRange != nil {
filter["create_time"] = timeRange
}
if len(filter) == 0 {
return nil, nil
}
return filter, nil
}
// buildMinutesSearchBody builds the POST body for the minutes search API.
func buildMinutesSearchBody(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
body := map[string]interface{}{}
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
body["query"] = q
}
filter, err := buildMinutesSearchFilter(runtime, startTime, endTime)
if err != nil {
return nil, err
}
if filter != nil {
body["filter"] = filter
}
return body, nil
}
// buildMinutesSearchParams builds the query parameters for the search request.
func buildMinutesSearchParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
pageSize := strings.TrimSpace(runtime.Str("page-size"))
if pageSize == "" {
pageSize = fmt.Sprintf("%d", defaultMinutesSearchPageSize)
}
params["page_size"] = pageSize
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
params["page_token"] = pageToken
}
return params
}
// minuteSearchItems extracts the result items from the API response payload.
func minuteSearchItems(data map[string]interface{}) []interface{} {
return common.GetSlice(data, "items")
}
// minuteSearchToken extracts the minute token from a search result item.
func minuteSearchToken(item map[string]interface{}) string {
return common.GetString(item, "token")
}
// minuteSearchDisplayInfo extracts the display_info field from a search result item.
func minuteSearchDisplayInfo(item map[string]interface{}) string {
return common.GetString(item, "display_info")
}
// minuteSearchDescription extracts the description field from a search result item.
func minuteSearchDescription(item map[string]interface{}) string {
meta := common.GetMap(item, "meta_data")
return common.GetString(meta, "description")
}
// minuteSearchAppLink extracts the app link from a search result item.
func minuteSearchAppLink(item map[string]interface{}) string {
meta := common.GetMap(item, "meta_data")
return common.GetString(meta, "app_link")
}
// minuteSearchAvatar extracts the avatar URL from a search result item.
func minuteSearchAvatar(item map[string]interface{}) string {
meta := common.GetMap(item, "meta_data")
return common.GetString(meta, "avatar")
}
// buildMinuteSearchRows converts API items into pretty output rows.
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
rows = append(rows, map[string]interface{}{
"token": minuteSearchToken(item),
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
"description": common.TruncateStr(minuteSearchDescription(item), 40),
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
"avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
})
}
return rows
}
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
var MinutesSearch = common.Shortcut{
Service: "minutes",
Command: "+search",
Description: "Search minutes by keyword, owners, participants, and time range",
Risk: "read",
Scopes: []string{"minutes:minutes.search:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "owner-ids", Desc: "owner open_id list, comma-separated (use \"me\" for current user)"},
{Name: "participant-ids", Desc: "participant open_id list, comma-separated (use \"me\" for current user)"},
{Name: "start", Desc: "time lower bound (ISO 8601 or YYYY-MM-DD)"},
{Name: "end", Desc: "time upper bound (ISO 8601 or YYYY-MM-DD)"},
{Name: "page-token", Desc: "page token for next page"},
{Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, _, err := parseTimeRange(runtime); err != nil {
return err
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
return output.ErrValidation("--query: length must be between 1 and 50 characters")
}
if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
return err
}
ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
if err != nil {
return err
}
for _, id := range ownerIDs {
if _, err := common.ValidateUserID(id); err != nil {
return err
}
}
participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
if err != nil {
return err
}
for _, id := range participantIDs {
if _, err := common.ValidateUserID(id); err != nil {
return err
}
}
for _, flag := range []string{"query", "owner-ids", "participant-ids", "start", "end"} {
if strings.TrimSpace(runtime.Str(flag)) != "" {
return nil
}
}
return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
startTime, endTime, err := parseTimeRange(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildMinutesSearchParams(runtime)
body, err := buildMinutesSearchBody(runtime, startTime, endTime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dryRun := common.NewDryRunAPI().
POST("/open-apis/minutes/v1/minutes/search")
if len(params) > 0 {
dryRun.Params(params)
}
return dryRun.Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
startTime, endTime, err := parseTimeRange(runtime)
if err != nil {
return err
}
body, err := buildMinutesSearchBody(runtime, startTime, endTime)
if err != nil {
return err
}
data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
items := minuteSearchItems(data)
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
rows := buildMinuteSearchRows(items)
outData := map[string]interface{}{
"items": items,
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
if len(rows) == 0 {
fmt.Fprintln(w, "No minutes.")
return
}
output.PrintTable(w, rows)
})
if hasMore && runtime.Format != "json" && runtime.Format != "" {
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
}
return nil
},
}

View File

@@ -0,0 +1,691 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newMinutesSearchTestCommand builds a command with the flags used by minutes search tests.
func newMinutesSearchTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("owner-ids", "", "")
cmd.Flags().String("participant-ids", "", "")
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().String("page-size", "15", "")
return cmd
}
// configWithoutUserOpenID returns a test config without a resolvable user open_id.
func configWithoutUserOpenID() *core.CliConfig {
cfg := defaultConfig()
cfg.UserOpenId = ""
return cfg
}
// TestMinutesSearchParseTimeRange verifies valid time inputs are normalized.
func TestMinutesSearchParseTimeRange(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("start", "2026-03-24")
_ = cmd.Flags().Set("end", "2026-03-25")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
start, end, err := parseTimeRange(runtime)
if err != nil {
t.Fatalf("parseTimeRange() unexpected error: %v", err)
}
if start == "" || end == "" {
t.Fatalf("expected non-empty start/end, got %q %q", start, end)
}
}
// TestMinutesSearchParseTimeRangeErrors verifies invalid time inputs return validation errors.
func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
start string
end string
wantMessage string
}{
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
if tt.start != "" {
_ = cmd.Flags().Set("start", tt.start)
}
if tt.end != "" {
_ = cmd.Flags().Set("end", tt.end)
}
_, _, err := parseTimeRange(common.TestNewRuntimeContext(cmd, defaultConfig()))
if err == nil {
t.Fatal("expected parseTimeRange error")
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
}
})
}
}
// TestBuildMinutesSearchParams verifies request params and body fields are assembled correctly.
func TestBuildMinutesSearchParams(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", "budget")
_ = cmd.Flags().Set("owner-ids", "ou_owner,ou_owner_2")
_ = cmd.Flags().Set("participant-ids", "ou_c")
_ = cmd.Flags().Set("page-size", "5")
_ = cmd.Flags().Set("page-token", "next_page")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
params := buildMinutesSearchParams(runtime)
body, err := buildMinutesSearchBody(runtime, "2026-03-24T00:00:00Z", "2026-03-25T00:00:00Z")
if err != nil {
t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
}
if got, _ := params["page_size"].(string); got != "5" {
t.Fatalf("page_size = %q, want 5", got)
}
if got, _ := params["page_token"].(string); got != "next_page" {
t.Fatalf("page_token = %q, want next_page", got)
}
if body["query"] != "budget" {
t.Fatalf("body.query = %v, want budget", body["query"])
}
filter, _ := body["filter"].(map[string]interface{})
if filter == nil {
t.Fatalf("body.filter = nil, want filter object")
}
owners, _ := filter["owner_ids"].([]string)
if len(owners) != 2 || owners[0] != "ou_owner" || owners[1] != "ou_owner_2" {
t.Fatalf("owner_ids = %v, want [ou_owner ou_owner_2]", filter["owner_ids"])
}
participants, _ := filter["participant_ids"].([]string)
if len(participants) != 1 || participants[0] != "ou_c" {
t.Fatalf("participant_ids = %v, want [ou_c]", filter["participant_ids"])
}
createTime, _ := filter["create_time"].(map[string]interface{})
if createTime == nil {
t.Fatalf("create_time = nil, want time range")
}
if createTime["start_time"] != "2026-03-24T00:00:00Z" {
t.Fatalf("start_time = %v", createTime["start_time"])
}
if createTime["end_time"] != "2026-03-25T00:00:00Z" {
t.Fatalf("end_time = %v", createTime["end_time"])
}
}
// TestBuildMinutesSearchParamsDefaultPageSize verifies the default page size is applied.
func TestBuildMinutesSearchParamsDefaultPageSize(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
params := buildMinutesSearchParams(common.TestNewRuntimeContext(cmd, defaultConfig()))
if got, _ := params["page_size"].(string); got != "15" {
t.Fatalf("page_size = %q, want 15", got)
}
}
// TestResolveUserIDs verifies me expansion, deduplication, and nil handling.
func TestResolveUserIDs(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "test"}
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
got, err := resolveUserIDs("--owner-ids", []string{"me"}, runtime)
if err != nil {
t.Fatalf("resolveUserIDs([me]) unexpected error: %v", err)
}
if len(got) != 1 || got[0] != "ou_testuser" {
t.Fatalf("resolveUserIDs([me]) = %v, want [ou_testuser]", got)
}
got, err = resolveUserIDs("--owner-ids", []string{"ou_other", "me", "Me"}, runtime)
if err != nil {
t.Fatalf("resolveUserIDs([ou_other, me, Me]) unexpected error: %v", err)
}
if len(got) != 2 || got[0] != "ou_other" || got[1] != "ou_testuser" {
t.Fatalf("resolveUserIDs([ou_other, me, Me]) = %v, want [ou_other ou_testuser]", got)
}
got, err = resolveUserIDs("--owner-ids", nil, runtime)
if err != nil {
t.Fatalf("resolveUserIDs(nil) unexpected error: %v", err)
}
if got != nil {
t.Fatalf("resolveUserIDs(nil) = %v, want nil", got)
}
}
// TestBuildTimeFilter verifies time filters are only populated for provided bounds.
func TestBuildTimeFilter(t *testing.T) {
t.Parallel()
if got := buildTimeFilter("", ""); got != nil {
t.Fatalf("buildTimeFilter('', '') = %v, want nil", got)
}
if got := buildTimeFilter("2026-03-24T00:00:00Z", ""); got["start_time"] != "2026-03-24T00:00:00Z" {
t.Fatalf("start_time = %v", got["start_time"])
}
if got := buildTimeFilter("", "2026-03-25T00:00:00Z"); got["end_time"] != "2026-03-25T00:00:00Z" {
t.Fatalf("end_time = %v", got["end_time"])
}
}
// TestMinutesSearchValidationMeOwnerID verifies owner-ids accepts me when open_id is available.
func TestMinutesSearchValidationMeOwnerID(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("owner-ids", "me")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error for --owner-ids me, got: %v", err)
}
}
// TestMinutesSearchValidationMeRequiresResolvableUser verifies me fails without a resolvable open_id.
func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) {
t.Parallel()
tests := []struct {
name string
flag string
}{
{name: "owner ids", flag: "owner-ids"},
{name: "participant ids", flag: "participant-ids"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set(tt.flag, "me")
runtime := common.TestNewRuntimeContext(cmd, configWithoutUserOpenID())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for unresolved me")
}
if !strings.Contains(err.Error(), "resolvable open_id") {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
// TestBuildMinutesSearchFilterMeExpansion verifies me is expanded inside the request filter.
func TestBuildMinutesSearchFilterMeExpansion(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("owner-ids", "me,ou_other")
_ = cmd.Flags().Set("participant-ids", "me")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body, err := buildMinutesSearchBody(runtime, "", "")
if err != nil {
t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
}
filter, _ := body["filter"].(map[string]interface{})
if filter == nil {
t.Fatal("body.filter = nil, want filter object")
}
owners, _ := filter["owner_ids"].([]string)
if len(owners) != 2 || owners[0] != "ou_testuser" || owners[1] != "ou_other" {
t.Fatalf("owner_ids = %v, want [ou_testuser ou_other]", owners)
}
participants, _ := filter["participant_ids"].([]string)
if len(participants) != 1 || participants[0] != "ou_testuser" {
t.Fatalf("participant_ids = %v, want [ou_testuser]", participants)
}
}
// TestMinuteSearchItems verifies items extraction from the search response payload.
func TestMinuteSearchItems(t *testing.T) {
t.Parallel()
items := minuteSearchItems(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"minute_token": "tok_1"}},
})
if len(items) != 1 {
t.Fatalf("minuteSearchItems() len = %d, want 1", len(items))
}
if got := minuteSearchItems(map[string]interface{}{}); got != nil {
t.Fatalf("minuteSearchItems() = %v, want nil", got)
}
}
// TestMinutesSearchValidationNoFilter verifies at least one filter is required.
func TestMinutesSearchValidationNoFilter(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, MinutesSearch, []string{"+search", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for empty filters")
}
if !strings.Contains(err.Error(), "specify at least one") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("participant-ids", "user_123")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected invalid user ID error")
}
}
// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("owner-ids", "user_123")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected invalid owner ID error")
}
}
// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
func TestMinutesSearchValidationQueryTooLong(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", strings.Repeat("a", 51))
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected query length error")
}
if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
func TestMinutesSearchValidationMaxPageSize30(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", "budget")
_ = cmd.Flags().Set("page-size", "30")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error for --page-size 30, got: %v", err)
}
}
// TestMinutesSearchValidationPageSizeAboveMax verifies page sizes above the limit are rejected.
func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", "budget")
_ = cmd.Flags().Set("page-size", "31")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for --page-size 31")
}
if !strings.Contains(err.Error(), "--page-size") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
func TestMinutesSearchValidationTimeErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
start string
end string
wantMessage string
}{
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", "budget")
if tt.start != "" {
_ = cmd.Flags().Set("start", tt.start)
}
if tt.end != "" {
_ = cmd.Flags().Set("end", tt.end)
}
err := MinutesSearch.Validate(context.Background(), common.TestNewRuntimeContext(cmd, defaultConfig()))
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
}
})
}
}
// TestMinutesSearchDryRun verifies dry-run output includes the expected API request details.
func TestMinutesSearchDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "ou_owner,ou_owner_2", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/search") {
t.Fatalf("dry-run should show API path, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "\"method\": \"POST\"") {
t.Fatalf("dry-run should use POST, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "\"query\": \"budget\"") {
t.Fatalf("dry-run should show query in body, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "\"owner_ids\": [") || !strings.Contains(stdout.String(), "\"ou_owner\"") {
t.Fatalf("dry-run should show owner_ids in filter, got: %s", stdout.String())
}
}
// TestMinutesSearchExecuteRendersRowsAndMoreHint verifies pretty output renders rows and pagination hints.
func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"token": "minute_1",
"display_info": "周会摘要",
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
},
},
"total": 1,
"has_more": true,
"page_token": "next_token",
},
},
}
reg.Register(searchStub)
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "me", "--format", "pretty", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var body map[string]interface{}
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal request body: %v", err)
}
if body["query"] != "budget" {
t.Fatalf("request query = %v, want budget", body["query"])
}
filter, _ := body["filter"].(map[string]interface{})
if filter == nil {
t.Fatalf("request filter = %v, want object", body["filter"])
}
owners, _ := filter["owner_ids"].([]interface{})
if len(owners) != 1 || owners[0] != "ou_testuser" {
t.Fatalf("request owner_ids = %v, want [ou_testuser]", filter["owner_ids"])
}
out := stdout.String()
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q, got: %s", want, out)
}
}
}
// TestMinutesSearchExecuteNoMinutes verifies empty results render the no-data message.
func TestMinutesSearchExecuteNoMinutes(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
},
})
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "pretty", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
if !strings.Contains(stdout.String(), "No minutes.") {
t.Fatalf("expected no minutes message, got: %s", stdout.String())
}
}
// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies table output includes pagination hints.
func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"token": "minute_1",
"display_info": "周会摘要",
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
},
},
"total": 1,
"has_more": true,
"page_token": "next_token",
},
},
})
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "table", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := stdout.String()
if !strings.Contains(out, "next_token") || !strings.Contains(out, "more available") {
t.Fatalf("expected pagination hint in table output, got: %s", out)
}
}
// TestMinutesSearchExecuteJSONCountUsesRenderedRows verifies JSON metadata counts rendered rows only.
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
nil,
map[string]interface{}{
"token": "minute_1",
"display_info": "周会摘要",
"meta_data": map[string]interface{}{
"description": "周会纪要",
},
},
},
"total": 2,
"has_more": false,
"page_token": "",
},
},
})
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var envelope struct {
Meta struct {
Count int `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String())
}
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
}
}
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
func TestMinuteSearchFieldExtractors(t *testing.T) {
t.Parallel()
item := map[string]interface{}{
"token": "minute_1",
"display_info": "<h>周会</h>摘要",
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
}
if got := minuteSearchToken(item); got != "minute_1" {
t.Fatalf("minuteSearchToken() = %q, want minute_1", got)
}
if got := minuteSearchDisplayInfo(item); got != "<h>周会</h>摘要" {
t.Fatalf("minuteSearchDisplayInfo() = %q", got)
}
if got := minuteSearchDescription(item); got != "周会纪要" {
t.Fatalf("minuteSearchDescription() = %q, want 周会纪要", got)
}
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
t.Fatalf("minuteSearchAvatar() = %q", got)
}
}
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
t.Parallel()
item := map[string]interface{}{
"token": "minute_2",
"display_info": "回退摘要",
"meta_data": map[string]interface{}{
"description": "回退纪要",
"app_link": "https://meetings.feishu.cn/minutes/fallback",
"avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
},
}
if got := minuteSearchToken(item); got != "minute_2" {
t.Fatalf("minuteSearchToken() = %q, want minute_2", got)
}
if got := minuteSearchDescription(item); got != "回退纪要" {
t.Fatalf("minuteSearchDescription() = %q, want 回退纪要", got)
}
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
t.Fatalf("minuteSearchAvatar() = %q", got)
}
}
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
t.Parallel()
item := map[string]interface{}{
"token": "minute_3",
"display_info": "无元信息摘要",
}
if got := minuteSearchToken(item); got != "minute_3" {
t.Fatalf("minuteSearchToken() = %q, want minute_3", got)
}
if got := minuteSearchDescription(item); got != "" {
t.Fatalf("minuteSearchDescription() = %q, want empty", got)
}
if got := minuteSearchAppLink(item); got != "" {
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
}
if got := minuteSearchAvatar(item); got != "" {
t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
}
}

View File

@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all minutes shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MinutesSearch,
MinutesDownload,
}
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/larksuite/cli/shortcuts/mail"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/sheets"
"github.com/larksuite/cli/shortcuts/slides"
"github.com/larksuite/cli/shortcuts/task"
"github.com/larksuite/cli/shortcuts/vc"
"github.com/larksuite/cli/shortcuts/whiteboard"
@@ -38,6 +39,7 @@ func init() {
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetAddDimension = common.Shortcut{
Service: "sheets",
Command: "+add-dimension",
Description: "Add rows or columns at the end of a sheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
length := runtime.Int("length")
if length < 1 || length > 5000 {
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"length": runtime.Int("length"),
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"length": runtime.Int("length"),
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetBatchSetStyle = common.Shortcut{
Service: "sheets",
Command: "+batch-set-style",
Description: "Batch set cell styles for multiple ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
var data interface{}
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
arr, ok := data.([]interface{})
if !ok || len(arr) == 0 {
return common.FlagErrorf("--data must be a non-empty JSON array")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
json.Unmarshal([]byte(runtime.Str("data")), &data)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
Body(map[string]interface{}{
"data": data,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
result, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"data": data,
},
)
if err != nil {
return err
}
runtime.Out(result, nil)
return nil
},
}

View File

@@ -0,0 +1,539 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── MergeCells ───────────────────────────────────────────────────────────────
func TestSheetMergeCellsValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
}, nil)
err := SheetMergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMergeCellsValidateRelativeRangeWithoutSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
}, nil)
err := SheetMergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
t.Fatalf("expected sheet-id error, got: %v", err)
}
}
func TestSheetMergeCellsValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ROWS",
}, nil)
if err := SheetMergeCells.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMergeCellsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", "merge-type": "MERGE_ALL",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetMergeCells.DryRun(context.Background(), rt))
if !strings.Contains(got, `merge_cells`) {
t.Fatalf("DryRun URL missing merge_cells: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun range not normalized: %s", got)
}
if !strings.Contains(got, `"mergeType":"MERGE_ALL"`) {
t.Fatalf("DryRun missing mergeType: %s", got)
}
}
func TestSheetMergeCellsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
})
err := mountAndRunSheets(t, SheetMergeCells, []string{
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "spreadsheetToken") {
t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String())
}
}
func TestSheetMergeCellsExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetMergeCells, []string{
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UnmergeCells ─────────────────────────────────────────────────────────────
func TestSheetUnmergeCellsValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
err := SheetUnmergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetUnmergeCellsValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
if err := SheetUnmergeCells.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUnmergeCellsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUnmergeCells.DryRun(context.Background(), rt))
if !strings.Contains(got, `unmerge_cells`) {
t.Fatalf("DryRun URL missing unmerge_cells: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun missing range: %s", got)
}
}
func TestSheetUnmergeCellsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
})
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUnmergeCellsExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── Replace ──────────────────────────────────────────────────────────────────
func TestSheetReplaceValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "find": "a", "replacement": "b", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
err := SheetReplace.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetReplaceValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "find": "hello", "replacement": "world", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetReplaceValidateMismatchedRangeSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
"range": "sheet2!A1:B2",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
err := SheetReplace.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("expected mismatch error, got: %v", err)
}
}
func TestSheetReplaceValidateMatchingRangeSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
"range": "sheet1!A1:B2",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetReplaceDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "old", "replacement": "new", "range": "A1:C5",
}, map[string]bool{"match-case": true, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
if !strings.Contains(got, `replace`) {
t.Fatalf("DryRun URL missing replace: %s", got)
}
if !strings.Contains(got, `"find":"old"`) {
t.Fatalf("DryRun missing find: %s", got)
}
if !strings.Contains(got, `"replacement":"new"`) {
t.Fatalf("DryRun missing replacement: %s", got)
}
if !strings.Contains(got, `"match_case":true`) {
t.Fatalf("DryRun missing match_case: %s", got)
}
}
func TestSheetReplaceDryRunNoRange(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "a", "replacement": "b", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
// When no range specified, range defaults to sheet-id
if !strings.Contains(got, `"range":"sheet1"`) {
t.Fatalf("DryRun range should default to sheet-id: %s", got)
}
}
func TestSheetReplaceExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"replace_result": map[string]interface{}{
"matched_cells": []interface{}{"A1"}, "rows_count": float64(1),
},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetReplace, []string{
"+replace", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--find", "hello", "--replacement", "world", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "matched_cells") {
t.Fatalf("stdout missing matched_cells: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["find"] != "hello" || body["replacement"] != "world" {
t.Fatalf("unexpected body: %#v", body)
}
}
func TestSheetReplaceExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetReplace, []string{
"+replace", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--find", "a", "--replacement", "b", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── SetStyle ─────────────────────────────────────────────────────────────────
func TestSheetSetStyleValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{"font":{"bold":true}}`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetSetStyleValidateInvalidJSON(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{invalid}`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--style must be valid JSON") {
t.Fatalf("expected JSON error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `[{"bold":true}]`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsString(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `"bold"`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsNull(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `null`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{"font":{"bold":true},"backColor":"#ff0000"}`,
}, nil)
if err := SheetSetStyle.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetSetStyleDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1",
"style": `{"font":{"bold":true}}`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `/style`) {
t.Fatalf("DryRun URL missing /style: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun range not normalized: %s", got)
}
if !strings.Contains(got, `"bold":true`) {
t.Fatalf("DryRun missing style: %s", got)
}
}
func TestSheetSetStyleExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"updates": map[string]interface{}{"updatedCells": float64(4), "updatedRange": "sheet1!A1:B2"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "updatedCells") {
t.Fatalf("stdout missing updatedCells: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
appendStyle, _ := body["appendStyle"].(map[string]interface{})
if appendStyle["range"] != "sheet1!A1:B2" {
t.Fatalf("unexpected range: %v", appendStyle["range"])
}
}
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── BatchSetStyle ────────────────────────────────────────────────────────────
func TestSheetBatchSetStyleValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateInvalidJSON(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `not-json`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--data must be valid JSON") {
t.Fatalf("expected JSON error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateNotArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `{"not":"array"}`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
t.Fatalf("expected array error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateEmptyArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `[]`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
t.Fatalf("expected empty array error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
if err := SheetBatchSetStyle.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetBatchSetStyleDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"backColor":"#ff0000"}}]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `styles_batch_update`) {
t.Fatalf("DryRun URL missing styles_batch_update: %s", got)
}
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
}
func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"totalUpdatedCells": float64(4), "revision": float64(90),
}},
})
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "totalUpdatedCells") {
t.Fatalf("stdout missing totalUpdatedCells: %s", stdout.String())
}
}
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{}}]`, "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetDeleteDimension = common.Shortcut{
Service: "sheets",
Command: "+delete-dimension",
Description: "Delete rows or columns",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
data, err := runtime.CallAPI("DELETE",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,923 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"strconv"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// newDimTestRuntime creates a RuntimeContext with string, int, and bool flags.
func newDimTestRuntime(t *testing.T, strFlags map[string]string, intFlags map[string]int, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for name := range strFlags {
cmd.Flags().String(name, "", "")
}
for name := range intFlags {
cmd.Flags().Int(name, 0, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, value := range strFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, value := range intFlags {
if err := cmd.Flags().Set(name, strconv.Itoa(value)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, value := range boolFlags {
if err := cmd.Flags().Set(name, strconv.FormatBool(value)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
func marshalDryRun(t *testing.T, v interface{}) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
return string(b)
}
// ── AddDimension ─────────────────────────────────────────────────────────────
func TestSheetAddDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": 10}, nil)
err := SheetAddDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetAddDimensionValidateLengthOutOfRange(t *testing.T) {
t.Parallel()
for _, length := range []int{0, -1, 5001} {
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": length}, nil)
err := SheetAddDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--length") {
t.Fatalf("length=%d: expected length error, got: %v", length, err)
}
}
}
func TestSheetAddDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": 100}, nil)
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetAddDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"length": 5}, nil)
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetAddDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"length": 8}, nil)
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"sheetId":"sheet1"`) {
t.Fatalf("DryRun missing sheetId: %s", got)
}
if !strings.Contains(got, `"majorDimension":"ROWS"`) {
t.Fatalf("DryRun missing majorDimension: %s", got)
}
if !strings.Contains(got, `"length":8`) {
t.Fatalf("DryRun missing length: %s", got)
}
}
func TestSheetAddDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"length": 3}, nil)
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetAddDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "Success",
"data": map[string]interface{}{"addCount": float64(8), "majorDimension": "ROWS"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetAddDimension, []string{
"+add-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--length", "8",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"addCount"`) {
t.Fatalf("stdout missing addCount: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected request body: %#v", body)
}
}
func TestSheetAddDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetAddDimension, []string{
"+add-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--length", "8",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── InsertDimension ──────────────────────────────────────────────────────────
func TestSheetInsertDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 3}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateNegativeStartIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": -1, "end-index": 3}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateEndNotGreaterThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": 5, "end-index": 5}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 4}, nil)
if err := SheetInsertDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetInsertDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": "BEFORE"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `insert_dimension_range`) {
t.Fatalf("DryRun URL missing insert_dimension_range: %s", got)
}
if !strings.Contains(got, `"startIndex":3`) {
t.Fatalf("DryRun missing startIndex: %s", got)
}
if !strings.Contains(got, `"endIndex":7`) {
t.Fatalf("DryRun missing endIndex: %s", got)
}
if !strings.Contains(got, `"inheritStyle":"BEFORE"`) {
t.Fatalf("DryRun missing inheritStyle: %s", got)
}
}
func TestSheetInsertDimensionDryRunNoInheritStyle(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "COLUMNS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 2}, nil)
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
if strings.Contains(got, `inheritStyle`) {
t.Fatalf("DryRun should omit inheritStyle when empty: %s", got)
}
}
func TestSheetInsertDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--inherit-style", "AFTER",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected dimension: %#v", dim)
}
if body["inheritStyle"] != "AFTER" {
t.Fatalf("unexpected inheritStyle: %v", body["inheritStyle"])
}
}
func TestSheetInsertDimensionExecuteWithoutInheritStyle(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "0",
"--end-index", "2",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
if _, ok := body["inheritStyle"]; ok {
t.Fatalf("inheritStyle should be absent when not specified: %#v", body)
}
}
func TestSheetInsertDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "3",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── UpdateDimension ──────────────────────────────────────────────────────────
func TestSheetUpdateDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateStartIndexLessThan1(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 5, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateNoProperties(t *testing.T) {
t.Parallel()
// Neither --visible nor --fixed-size is set (Changed returns false)
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3}, nil)
// Register the flags but don't set them so Changed() returns false
rt.Cmd.Flags().Bool("visible", false, "")
rt.Cmd.Flags().Int("fixed-size", 0, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--visible or --fixed-size") {
t.Fatalf("expected properties error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateSuccessWithVisible(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3},
map[string]bool{"visible": true})
// Ensure fixed-size flag exists but is not set
rt.Cmd.Flags().Int("fixed-size", 0, "")
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUpdateDimensionValidateFixedSizeZero(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 0}, nil)
rt.Cmd.Flags().Bool("visible", false, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
t.Fatalf("expected fixed-size error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateFixedSizeNegative(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": -10}, nil)
rt.Cmd.Flags().Bool("visible", false, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
t.Fatalf("expected fixed-size error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateSuccessWithFixedSize(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 5, "fixed-size": 120}, nil)
// Ensure visible flag exists but is not set
rt.Cmd.Flags().Bool("visible", false, "")
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUpdateDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"visible":true`) {
t.Fatalf("DryRun missing visible: %s", got)
}
if !strings.Contains(got, `"fixedSize":50`) {
t.Fatalf("DryRun missing fixedSize: %s", got)
}
}
func TestSheetUpdateDimensionDryRunOnlyVisible(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3},
map[string]bool{"visible": false})
// Add fixed-size flag but don't set it
rt.Cmd.Flags().Int("fixed-size", 0, "")
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"visible":false`) {
t.Fatalf("DryRun missing visible: %s", got)
}
if strings.Contains(got, `fixedSize`) {
t.Fatalf("DryRun should omit fixedSize when not set: %s", got)
}
}
func TestSheetUpdateDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
"+update-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "1",
"--end-index", "3",
"--visible=true",
"--fixed-size", "50",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
props, _ := body["dimensionProperties"].(map[string]interface{})
if props["visible"] != true {
t.Fatalf("expected visible=true, got: %#v", props)
}
if props["fixedSize"] != float64(50) {
t.Fatalf("expected fixedSize=50, got: %#v", props)
}
}
func TestSheetUpdateDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
"+update-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "1",
"--end-index", "3",
"--visible=true",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── MoveDimension ────────────────────────────────────────────────────────────
func TestSheetMoveDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateNegativeStartIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": -1, "end-index": 1, "destination-index": 4}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 5, "end-index": 3, "destination-index": 0}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateNegativeDestinationIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": -1}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--destination-index") {
t.Fatalf("expected destination-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 0, "end-index": 2, "destination-index": 5}, nil)
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `move_dimension`) {
t.Fatalf("DryRun URL missing move_dimension: %s", got)
}
if !strings.Contains(got, `"major_dimension":"ROWS"`) {
t.Fatalf("DryRun missing major_dimension: %s", got)
}
if !strings.Contains(got, `"start_index":0`) {
t.Fatalf("DryRun missing start_index: %s", got)
}
if !strings.Contains(got, `"destination_index":4`) {
t.Fatalf("DryRun missing destination_index: %s", got)
}
}
func TestSheetMoveDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 3, "destination-index": 0}, nil)
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetMoveDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "1",
"--destination-index", "4",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
source, _ := body["source"].(map[string]interface{})
if source["major_dimension"] != "ROWS" {
t.Fatalf("unexpected major_dimension: %v", source["major_dimension"])
}
if body["destination_index"] != float64(4) {
t.Fatalf("unexpected destination_index: %v", body["destination_index"])
}
}
func TestSheetMoveDimensionExecuteWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/move_dimension",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "1",
"--end-index", "2",
"--destination-index", "0",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
Status: 400,
Body: map[string]interface{}{"code": 1310211, "msg": "wrong sheet id"},
})
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "1",
"--destination-index", "4",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── DeleteDimension ──────────────────────────────────────────────────────────
func TestSheetDeleteDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateStartIndexLessThan1(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 5, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 2}, nil)
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"startIndex":3`) {
t.Fatalf("DryRun missing startIndex: %s", got)
}
if !strings.Contains(got, `"endIndex":7`) {
t.Fatalf("DryRun missing endIndex: %s", got)
}
}
func TestSheetDeleteDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 5}, nil)
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetDeleteDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"delCount": float64(5), "majorDimension": "ROWS"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"delCount"`) {
t.Fatalf("stdout missing delCount: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected dimension: %#v", dim)
}
}
func TestSheetDeleteDimensionExecuteWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"delCount": float64(2), "majorDimension": "COLUMNS"},
},
})
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "1",
"--end-index", "2",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetInsertDimension = common.Shortcut{
Service: "sheets",
Command: "+insert-dimension",
Description: "Insert rows or columns at a specified position",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true},
{Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
}
if runtime.Int("end-index") <= runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be greater than --start-index")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
body := map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}
if s := runtime.Str("inherit-style"); s != "" {
body["inheritStyle"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range").
Body(body).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
body := map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}
if s := runtime.Str("inherit-style"); s != "" {
body["inheritStyle"] = s
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
nil, body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetMergeCells = common.Shortcut{
Service: "sheets",
Command: "+merge-cells",
Description: "Merge cells in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
{Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells").
Body(map[string]interface{}{
"range": r,
"mergeType": runtime.Str("merge-type"),
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"range": r,
"mergeType": runtime.Str("merge-type"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetMoveDimension = common.Shortcut{
Service: "sheets",
Command: "+move-dimension",
Description: "Move rows or columns to a new position",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true},
{Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true},
{Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
if runtime.Int("destination-index") < 0 {
return common.FlagErrorf("--destination-index must be >= 0")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension").
Body(map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": runtime.Str("dimension"),
"start_index": runtime.Int("start-index"),
"end_index": runtime.Int("end-index"),
},
"destination_index": runtime.Int("destination-index"),
}).
Set("token", token).
Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
validate.EncodePathSegment(token),
validate.EncodePathSegment(runtime.Str("sheet-id")),
),
nil,
map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": runtime.Str("dimension"),
"start_index": runtime.Int("start-index"),
"end_index": runtime.Int("end-index"),
},
"destination_index": runtime.Int("destination-index"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetReplace = common.Shortcut{
Service: "sheets",
Command: "+replace",
Description: "Find and replace cell values in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "find", Desc: "search text or regex pattern", Required: true},
{Name: "replacement", Desc: "replacement text", Required: true},
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 with --sheet-id)"},
{Name: "match-case", Type: "bool", Desc: "case-sensitive search"},
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"},
{Name: "search-by-regex", Type: "bool", Desc: "use regex search"},
{Name: "include-formulas", Type: "bool", Desc: "search in formulas"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": runtime.Bool("match-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace").
Body(map[string]interface{}{
"find_condition": findCondition,
"find": runtime.Str("find"),
"replacement": runtime.Str("replacement"),
}).
Set("token", token).Set("sheet_id", sheetID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": runtime.Bool("match-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
validate.EncodePathSegment(token),
validate.EncodePathSegment(sheetID),
),
nil,
map[string]interface{}{
"find_condition": findCondition,
"find": runtime.Str("find"),
"replacement": runtime.Str("replacement"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetSetStyle = common.Shortcut{
Service: "sheets",
Command: "+set-style",
Description: "Set cell style for a range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
{Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
}
if _, ok := style.(map[string]interface{}); !ok {
return common.FlagErrorf("--style must be a JSON object, got %T", style)
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
json.Unmarshal([]byte(runtime.Str("style")), &style)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
Body(map[string]interface{}{
"appendStyle": map[string]interface{}{
"range": r,
"style": style,
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
}
data, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"appendStyle": map[string]interface{}{
"range": r,
"style": style,
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetUnmergeCells = common.Shortcut{
Service: "sheets",
Command: "+unmerge-cells",
Description: "Unmerge (split) cells in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells").
Body(map[string]interface{}{
"range": r,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"range": r,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetUpdateDimension = common.Shortcut{
Service: "sheets",
Command: "+update-dimension",
Description: "Update row or column properties (visibility, size)",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
{Name: "visible", Type: "bool", Desc: "true to show, false to hide"},
{Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
}
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
return common.FlagErrorf("--fixed-size must be >= 1")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
props := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("visible") {
props["visible"] = runtime.Bool("visible")
}
if runtime.Cmd.Flags().Changed("fixed-size") {
props["fixedSize"] = runtime.Int("fixed-size")
}
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
"dimensionProperties": props,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
props := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("visible") {
props["visible"] = runtime.Bool("visible")
}
if runtime.Cmd.Flags().Changed("fixed-size") {
props["fixedSize"] = runtime.Int("fixed-size")
}
data, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
"dimensionProperties": props,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -16,5 +16,15 @@ func Shortcuts() []common.Shortcut {
SheetFind,
SheetCreate,
SheetExport,
SheetMergeCells,
SheetUnmergeCells,
SheetReplace,
SheetSetStyle,
SheetBatchSetStyle,
SheetAddDimension,
SheetInsertDimension,
SheetUpdateDimension,
SheetMoveDimension,
SheetDeleteDimension,
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
}
}

View File

@@ -0,0 +1,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultPresentationWidth = 960
defaultPresentationHeight = 540
maxSlidesPerCreate = 10
)
// SlidesCreate creates a new Lark Slides presentation with bot auto-grant.
var SlidesCreate = common.Shortcut{
Service: "slides",
Command: "+create",
Description: "Create a Lark Slides presentation",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only"},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if slidesStr := runtime.Str("slides"); slidesStr != "" {
var slides []string
if err := json.Unmarshal([]byte(slidesStr), &slides); err != nil {
return common.FlagErrorf("--slides invalid JSON, must be an array of XML strings")
}
if len(slides) > maxSlidesPerCreate {
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
title := effectiveTitle(runtime.Str("title"))
slidesStr := runtime.Str("slides")
createBody := map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
}
dry := common.NewDryRunAPI()
if slidesStr == "" {
dry.Desc("Create empty presentation").
POST("/open-apis/slides_ai/v1/xml_presentations").
Body(createBody)
} else {
var slides []string
_ = json.Unmarshal([]byte(slidesStr), &slides)
n := len(slides)
total := n + 1
dry.Desc(fmt.Sprintf("Create presentation + add %d slide(s)", n)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(createBody)
for i, slideXML := range slides {
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add slide %d", i+2, total, i+1)).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
})
}
}
if runtime.IsBot() {
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
content := buildPresentationXML(title)
slidesStr := runtime.Str("slides")
// Step 1: Create presentation
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
}
// Step 2: Add slides if provided
if slidesStr != "" {
var slides []string
_ = json.Unmarshal([]byte(slidesStr), &slides) // already validated
if len(slides) > 0 {
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
var slideIDs []string
for i, slideXML := range slides {
slideData, err := runtime.CallAPI(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
},
)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"slide %d/%d failed: %v (presentation %s was created; %d slide(s) added before failure)",
i+1, len(slides), err, presentationID, i)
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
}
}
// Fetch presentation URL via drive meta (best-effort)
if metaData, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": presentationID,
"doc_type": "slides",
},
},
"with_url": true,
},
); err == nil {
metas := common.GetSlice(metaData, "metas")
if len(metas) > 0 {
if meta, ok := metas[0].(map[string]interface{}); ok {
if url := common.GetString(meta, "url"); url != "" {
result["url"] = url
}
}
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
runtime.Out(result, nil)
return nil
},
}
// effectiveTitle returns the title to use, falling back to "Untitled".
func effectiveTitle(title string) string {
if title == "" {
return "Untitled"
}
return title
}
// buildPresentationXML builds the minimal XML for a new empty presentation.
func buildPresentationXML(title string) string {
escapedTitle := xmlEscape(title)
if escapedTitle == "" {
escapedTitle = "Untitled"
}
return fmt.Sprintf(
`<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="%d" height="%d"><title>%s</title></presentation>`,
defaultPresentationWidth, defaultPresentationHeight, escapedTitle,
)
}
// xmlEscape escapes special XML characters in text content.
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

View File

@@ -0,0 +1,653 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// TestSlidesCreateBasic verifies that slides +create returns the presentation ID, title, and URL in user mode.
func TestSlidesCreateBasic(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_abc123",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_abc123", "https://example.feishu.cn/slides/pres_abc123")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目汇报",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_abc123" {
t.Fatalf("xml_presentation_id = %v, want pres_abc123", data["xml_presentation_id"])
}
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
if data["url"] != "https://example.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://example.feishu.cn/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
}
}
// TestSlidesCreateBotAutoGrant verifies that bot mode grants the current user full_access on the new presentation.
func TestSlidesCreateBotAutoGrant(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_bot",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_bot", "https://example.feishu.cn/slides/pres_bot")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/pres_bot/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Bot PPT",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantGranted)
}
if !strings.Contains(grant["message"].(string), "presentation") {
t.Fatalf("permission_grant.message = %q, want 'presentation' mention", grant["message"])
}
}
// TestSlidesCreateBotSkippedWithoutCurrentUser verifies that permission grant is skipped when no user open_id is configured.
func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_no_user",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_no_user", "https://example.feishu.cn/slides/pres_no_user")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No User PPT",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantSkipped)
}
}
// TestSlidesCreateDryRunDefaultTitle verifies that dry-run also normalizes an empty title to "Untitled".
func TestSlidesCreateDryRunDefaultTitle(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Untitled") {
t.Fatalf("dry-run should contain Untitled in XML payload, got: %s", out)
}
if !strings.Contains(out, "xml_presentations") {
t.Fatalf("dry-run should show API path, got: %s", out)
}
}
// TestSlidesCreateDefaultTitle verifies that omitting --title outputs "Untitled" (matching the actual resource).
func TestSlidesCreateDefaultTitle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_default",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_default", "https://example.feishu.cn/slides/pres_default")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["title"] != "Untitled" {
t.Fatalf("title = %v, want Untitled", data["title"])
}
}
// TestSlidesCreateMissingPresentationID verifies the error when the API returns no xml_presentation_id.
func TestSlidesCreateMissingPresentationID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"revision_id": 1,
},
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Missing ID",
"--as", "user",
})
if err == nil {
t.Fatal("expected error when xml_presentation_id is missing, got nil")
}
if !strings.Contains(err.Error(), "xml_presentation_id") {
t.Fatalf("error = %q, want mention of xml_presentation_id", err.Error())
}
}
// TestSlidesCreateWithSlides verifies that slides +create with --slides creates the presentation and adds slides.
func TestSlidesCreateWithSlides(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_with_slides",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_with_slides", "https://example.feishu.cn/slides/pres_with_slides")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"slide_id": "slide_001",
"revision_id": 2,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"slide_id": "slide_002",
"revision_id": 3,
},
},
})
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "With Slides",
"--slides", slidesJSON,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_with_slides" {
t.Fatalf("xml_presentation_id = %v, want pres_with_slides", data["xml_presentation_id"])
}
slideIDs, ok := data["slide_ids"].([]interface{})
if !ok || len(slideIDs) != 2 {
t.Fatalf("slide_ids = %v, want 2 elements", data["slide_ids"])
}
if slideIDs[0] != "slide_001" || slideIDs[1] != "slide_002" {
t.Fatalf("slide_ids = %v, want [slide_001, slide_002]", slideIDs)
}
if data["slides_added"] != float64(2) {
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
}
}
// TestSlidesCreateWithSlidesPartialFailure verifies error reporting when a slide fails to create.
func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_partial",
"revision_id": 1,
},
},
})
// First slide succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_partial/slide",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"slide_id": "slide_ok",
"revision_id": 2,
},
},
})
// Second slide fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_partial/slide",
Body: map[string]interface{}{
"code": 400,
"msg": "invalid xml",
},
})
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<bad-xml>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Partial",
"--slides", slidesJSON,
"--as", "user",
})
if err == nil {
t.Fatal("expected error for partial failure, got nil")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "pres_partial") {
t.Fatalf("error should contain presentation ID, got: %s", errMsg)
}
if !strings.Contains(errMsg, "slide 2/2") {
t.Fatalf("error should indicate slide 2/2 failed, got: %s", errMsg)
}
if !strings.Contains(errMsg, "1 slide(s) added") {
t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg)
}
}
// TestSlidesCreateWithSlidesInvalidJSON verifies validation rejects non-JSON slides input.
func TestSlidesCreateWithSlidesInvalidJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Bad JSON",
"--slides", "not json",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for invalid JSON, got nil")
}
if !strings.Contains(err.Error(), "--slides invalid JSON") {
t.Fatalf("error = %q, want --slides invalid JSON mention", err.Error())
}
}
// TestSlidesCreateWithSlidesExceedsMax verifies validation rejects arrays exceeding the limit.
func TestSlidesCreateWithSlidesExceedsMax(t *testing.T) {
t.Parallel()
// Build a JSON array with 11 elements (exceeds maxSlidesPerCreate = 10)
elems := make([]string, 11)
for i := range elems {
elems[i] = `"<slide/>"` //nolint:goconst
}
slidesJSON := "[" + strings.Join(elems, ",") + "]"
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Too Many",
"--slides", slidesJSON,
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for exceeding max, got nil")
}
if !strings.Contains(err.Error(), "exceeds maximum") {
t.Fatalf("error = %q, want 'exceeds maximum' mention", err.Error())
}
}
// TestSlidesCreateWithSlidesEmptyArray verifies that --slides '[]' behaves like no --slides.
func TestSlidesCreateWithSlidesEmptyArray(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_empty_slides",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_empty_slides", "https://example.feishu.cn/slides/pres_empty_slides")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Empty Slides",
"--slides", "[]",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_empty_slides" {
t.Fatalf("xml_presentation_id = %v, want pres_empty_slides", data["xml_presentation_id"])
}
if _, ok := data["slide_ids"]; ok {
t.Fatalf("did not expect slide_ids for empty slides array")
}
if _, ok := data["slides_added"]; ok {
t.Fatalf("did not expect slides_added for empty slides array")
}
}
// TestSlidesCreateWithSlidesDryRun verifies dry-run output shows multi-step labels.
func TestSlidesCreateWithSlidesDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "DryRun Slides",
"--slides", slidesJSON,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "[1/3]") {
t.Fatalf("dry-run should contain [1/3] step label, got: %s", out)
}
if !strings.Contains(out, "[2/3]") {
t.Fatalf("dry-run should contain [2/3] step label, got: %s", out)
}
if !strings.Contains(out, "[3/3]") {
t.Fatalf("dry-run should contain [3/3] step label, got: %s", out)
}
if !strings.Contains(out, "xml_presentation_id") {
t.Fatalf("dry-run should contain placeholder xml_presentation_id, got: %s", out)
}
}
// TestSlidesCreateWithoutSlidesUnchanged verifies existing behavior when --slides is not passed.
func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_no_slides",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_no_slides", "https://example.feishu.cn/slides/pres_no_slides")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No Slides",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_no_slides" {
t.Fatalf("xml_presentation_id = %v, want pres_no_slides", data["xml_presentation_id"])
}
if data["title"] != "No Slides" {
t.Fatalf("title = %v, want No Slides", data["title"])
}
if _, ok := data["slide_ids"]; ok {
t.Fatalf("did not expect slide_ids when --slides not passed")
}
if _, ok := data["slides_added"]; ok {
t.Fatalf("did not expect slides_added when --slides not passed")
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
}
}
// TestSlidesCreateURLFetchBestEffort verifies that the shortcut succeeds even when batch_query fails.
func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_no_url",
"revision_id": 1,
},
},
})
// batch_query returns an error — URL fetch should be silently skipped
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 99999,
"msg": "no permission",
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No URL",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_no_url" {
t.Fatalf("xml_presentation_id = %v, want pres_no_url", data["xml_presentation_id"])
}
if _, ok := data["url"]; ok {
t.Fatalf("did not expect url when batch_query fails")
}
}
// TestXmlEscape verifies that XML special characters are properly escaped.
func TestXmlEscape(t *testing.T) {
t.Parallel()
tests := []struct {
input, want string
}{
{"hello", "hello"},
{"a&b", "a&amp;b"},
{"<script>", "&lt;script&gt;"},
{`"quoted"`, "&quot;quoted&quot;"},
{"it's", "it&apos;s"},
}
for _, tt := range tests {
got := xmlEscape(tt.input)
if got != tt.want {
t.Errorf("xmlEscape(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// slidesTestConfig returns a CliConfig for testing with the given user open ID.
func slidesTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-slides-create-" + suffix,
AppSecret: "secret-slides-create-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
// runSlidesCreateShortcut mounts and executes the slides +create shortcut with the given args.
func runSlidesCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
SlidesCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// registerBatchQueryStub registers a drive meta batch_query mock that returns the given URL.
func registerBatchQueryStub(reg *httpmock.Registry, token, url string) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": token, "doc_type": "slides", "title": "", "url": url},
},
},
},
})
}
// decodeSlidesCreateEnvelope parses the JSON output and returns the data map.
func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
return data
}

View File

@@ -30,6 +30,7 @@ var AddTaskToTasklist = common.Shortcut{
Flags: []common.Flag{
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "section-guid", Desc: "section guid"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -40,6 +41,10 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": extractTasklistGuid(runtime.Str("tasklist-id")),
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskId + "/add_tasklist").
Params(map[string]interface{}{"user_id_type": "open_id"}).
@@ -57,6 +62,10 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": tasklistGuid,
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
var successful []map[string]interface{}
var failed []map[string]interface{}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAddTaskToTasklist_Success(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/add_tasklist",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-1",
},
},
},
})
s := AddTaskToTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-1", "--section-guid", "sec-456", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) {
t.Errorf("expected tasklist_guid in output, got: %s", out)
}
}

View File

@@ -153,3 +153,22 @@ Drive Folder (云空间文件夹)
| `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` |
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet |
### 授权当前应用访问文档
当需要将文档权限授予**当前应用bot自身**时,先通过 bot info 接口获取应用的 open_id再调用权限接口授权
```bash
# 1. 获取当前应用的 open_id
lark-cli api GET /open-apis/bot/v3/info --as bot
# 从返回值中取 bot.open_id
# 2. 授权当前应用访问文档
lark-cli drive permission.members create \
--params '{"token":"<doc_token>","type":"<resource_type>"}' \
--data '{"member_type":"openid","member_id":"<bot_open_id>","perm":"view","type":"user"}'
```
> **注意**:此方式仅适用于需要授权给**当前应用**的场景。授权给其他用户时,直接使用对方的 open_id 即可,无需调用 bot info 接口。
`<resource_type>` 可选值:`doc`、`docx`、`sheet`、`bitable`、`file`、`folder`、`wiki`。

View File

@@ -0,0 +1,57 @@
---
name: lark-attendance
version: 1.0.0
description: "飞书考勤打卡:查询自己的考勤打卡记录"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli attendance --help"
---
# attendance (v1)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 默认参数自动填充规则
调用任何 API 时,以下参数 **必须自动填充,禁止向用户询问**
| 参数 | 固定值 | 说明 |
|------|--------|------------------------------------|
| `employee_type` | `"employee_no"` | `employee_type`始终等于`"employee_no"` |
| `user_ids` | `[]`(空数组) | `user_ids`始终等于`[]` |
### 填充示例
当构建 `--params` 参数时,自动注入上述字段:
- `employee_type` 保持 `"employee_no"` 不变
当构建 `--data` 参数时,自动注入上述字段:
```json
{
"user_ids": [],
...
}
```
> **注意**`user_ids` 数组保持为空[]`employee_type` 保持 `"employee_no"` 不变。
## API Resources
```bash
lark-cli schema attendance.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli attendance <resource> <method> [flags] # 调用 API
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### user_tasks
- `query` — 查询用户考勤打卡记录
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `user_tasks.query` | `attendance:task:readonly` |

View File

@@ -134,7 +134,8 @@ lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" b
- 使用标准 Markdown 语法作为基础
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
- 需要显示特殊字符时使用反斜杠转义:`* ~ ` $ [ ] < > { } | ^`
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3``version~1.0``final_trajectory` 通常应保持原样,只有像 `*斜体*``**粗体**``~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
---
@@ -657,7 +658,7 @@ $$
## 最佳实践
- **空行分隔**:不同块类型之间用空行分隔
- **转义字符**特殊字符用 `\` 转义:`\*` `\~` `\``
- **转义字符**只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3``version~1.0``final_trajectory` 这类普通文本通常不需要转义
- **图片**:使用 URL系统自动下载上传
- **分栏**:列宽总和必须为 100
- **表格选择**:简单数据用 Markdown复杂嵌套用 `<lark-table>`

View File

@@ -167,6 +167,25 @@ Drive Folder (云空间文件夹)
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet |
### 授权当前应用访问文档
当需要将文档权限授予**当前应用bot自身**时,先通过 bot info 接口获取应用的 open_id再调用权限接口授权
```bash
# 1. 获取当前应用的 open_id
lark-cli api GET /open-apis/bot/v3/info --as bot
# 从返回值中取 bot.open_id
# 2. 授权当前应用访问文档
lark-cli drive permission.members create \
--params '{"token":"<doc_token>","type":"<resource_type>"}' \
--data '{"member_type":"openid","member_id":"<bot_open_id>","perm":"view","type":"user"}'
```
> **注意**:此方式仅适用于需要授权给**当前应用**的场景。授权给其他用户时,直接使用对方的 open_id 即可,无需调用 bot info 接口。
`<resource_type>` 可选值:`doc`、`docx`、`sheet`、`bitable`、`file`、`folder`、`wiki`。
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)。有 Shortcut 的操作优先使用。
@@ -175,11 +194,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
|----------|------|
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
## API Resources

View File

@@ -0,0 +1,103 @@
# drive +create-shortcut
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
在目标文件夹中为一个现有 Drive 文件创建快捷方式。
## 命令
```bash
# 为普通文件创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <FILE_TOKEN> \
--type file
# 为新版文档创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <DOCX_TOKEN> \
--type docx
# 为电子表格创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <SHEET_TOKEN> \
--type sheet
# 仅预览即将发起的请求,不真正执行
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <DOCX_TOKEN> \
--type docx \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--folder-token` | 是 | 目标父文件夹 token |
| `--file-token` | 是 | 源文件 token表示被引用的原始文件 |
| `--type` | 是 | 源文件类型,推荐值:`file``docx``doc``sheet``bitable``mindnote``slides` |
## 输入规则
- 该 shortcut 的最小输入是 `--folder-token` + `--file-token` + `--type`
- CLI 层会把 `--file-token``--type` 组装为底层 API 所需的 `refer_entity`
- `--file-token` 必须是 Drive 文件 token不要直接传 wiki 节点 token
- 如果来源是 `/wiki/...` 链接,必须先按 [`lark-drive`](../SKILL.md) 中的 wiki 解析流程拿到真实 `obj_token`,再创建快捷方式
- 目标位置必须是云空间文件夹;这个 shortcut 不是“复制文件内容”,而是“在另一个文件夹里挂一个引用入口”
## 类型说明
| 类型 | 说明 |
|------|------|
| `file` | 普通文件 |
| `docx` | 新版云文档 |
| `doc` | 旧版云文档 |
| `sheet` | 电子表格 |
| `bitable` | 多维表格 |
| `mindnote` | 思维笔记 |
| `slides` | 幻灯片 |
## 行为说明
- 成功时会调用 `POST /open-apis/drive/v1/files/create_shortcut`
- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto``--format``--jq``--dry-run` 使用
- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建快捷方式
- 这是写入操作;执行前应确认目标文件夹和源文件都准确无误
## 限制
- 该接口不支持并发调用
- 调用频率上限为 5 QPS且 10000 次/天
- 不支持跨租户、跨地域创建快捷方式
- 不支持跨品牌创建快捷方式
- 如果目标父文件夹单层挂载数量超过限制,会返回 `1062507`
## 权限要求
- 当前调用身份需要能访问源文件
- 当前调用身份需要对目标文件夹有编辑权限
- 如果权限不足,常见表现为 `1061004 forbidden`
## 常见错误
| 错误码 / 错误信息 | 原因 | 处理建议 |
|------|------|------|
| `1061002 params error` | 缺少必填参数,或 `--file-token` / `--type` 组合无法构成有效源文件信息 | 检查 `--file-token``--type` 是否完整且匹配;如显式传了 `--folder-token`,再确认其值有效 |
| `1061003 not found` | 源文件或目标文件夹不存在 | 重新确认 token 是否正确、资源是否已删除 |
| `1061004 forbidden` | 对源文件没有访问权限,或对目标文件夹没有编辑权限 | 切换到有权限的身份,或先授予文档 / 文件夹权限 |
| `1061005 auth failed` | 身份类型或 access token 不正确 | 检查 `--as` 使用的身份及当前登录态 |
| `1061007 file has been delete` | 源文件已删除 | 确认原文件仍存在,再重新执行 |
| `1062507 parent node out of sibling num` | 目标文件夹单层挂载数超过上限 | 清理目标目录,或换一个父文件夹 |
| `1061045 resource contention occurred, please retry` | 平台内部资源争抢 | 稍后重试,不要并发重复调用 |
| `1064510 cross tenant and unit not support` | 跨租户或跨地域请求 | 改为在同租户、同地域范围内操作 |
| `1064511 cross brand not support` | 跨品牌请求 | 改为在同品牌环境内操作 |
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,79 @@
# drive +delete
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
删除云空间内的文件或文件夹。删除后资源会进入回收站。
> [!CAUTION]
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
## 命令
```bash
# 删除普通文件
lark-cli drive +delete \
--file-token <FILE_TOKEN> \
--type file \
--yes
# 删除在线文档
lark-cli drive +delete \
--file-token <DOCX_TOKEN> \
--type docx \
--yes
# 删除文件夹(异步操作,会自动有限轮询任务状态)
lark-cli drive +delete \
--file-token <FOLDER_TOKEN> \
--type folder \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | 需要删除的文件或文件夹 token |
| `--type` | 是 | 文件类型,可选值:`file``docx``bitable``doc``sheet``mindnote``folder``shortcut``slides` |
| `--yes` | 是 | 确认执行高风险删除操作 |
## 行为说明
- **普通文件删除**:同步操作,成功时直接返回 `deleted=true`
- **文件夹删除**:异步操作,接口返回 `task_id`shortcut 会先做有限轮询;如果在轮询窗口内完成,则直接返回成功结果
- **轮询超时不是失败**:文件夹删除内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id``status``ready=false``timed_out=true``next_command`
- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>` 继续查询
- **状态值**`task_check` 的服务端状态通常是 `success``fail``process`
## 推荐续跑方式
```bash
# 第一步:先直接删除文件夹
lark-cli drive +delete \
--file-token <FOLDER_TOKEN> \
--type folder \
--yes
# 如果返回 ready=false / timed_out=true再继续查
lark-cli drive +task_result \
--scenario task_check \
--task-id <TASK_ID>
```
## 限制
- 该 shortcut 仅支持云空间文件或文件夹,不支持 wiki 文档
- 该接口不支持并发调用
- 调用频率上限为 5 QPS 且 10000 次/天
## 权限要求
- 删除文件时,调用身份需要满足以下其一:
- 是文件所有者,并且拥有该文件所在父文件夹的编辑权限
- 不是文件所有者,但拥有该父文件夹的 owner 或 full access 权限
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记:获取妙记基础信息(标题、封面、时长)和相关 AI 产物(总结、待办、章节),下载妙记音视频文件。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物(总结、待办、章节)。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
metadata:
requires:
bins: ["lark-cli"]
@@ -14,64 +14,84 @@ metadata:
## 核心概念
- **妙记 Tokenminute_token**:妙记的唯一标识符。通常可从妙记的 URL 链接中提取(例如 `https://*.feishu.cn/minutes/obcnq3b9jl72l83w4f14xxxx` 中的最后一段字符串 `obcnq3b9jl72l83w4f14xxxx`
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识
- **妙记 Tokenminute\_token**:妙记的唯一标识符,可从妙记 URL 末尾提取(例如 `https://*.feishu.cn/minutes/obcnq3b9jl72l83w4f14xxxx` 中的 `obcnq3b9jl72l83w4f14xxxx`)。如果 URL 中包含额外参数(如 `?xxx`),应截取路径最后一段。
## 使用说明
## 核心场景
1. **提取 Token**
- 只有 `minute_token` 参数是必填的。
- 如果 URL 中包含额外参数(如 `?xxx`),请截取路径部分的最后一段作为 token。
- 示例:从 `https://domain.feishu.cn/minutes/obc123456?project=xxx` 中提取出 `obc123456`
### 1. 搜索妙记
2. **获取妙记信息**
- 使用 `lark-cli schema minutes.minutes.get` 可以查看具体的返回值结构
- 返回的核心字段通常包含:
- `title`:会议标题
- `cover`:视频/音频封面 URL
- `duration`:会议时长(毫秒)
- `owner_id`:所有者 ID
- `url`:妙记链接
1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
4. 如果是会议的妙记,应优先使用 [vc +search](../lark-vc/references/lark-vc-search.md) 先定位会议,再按需通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
## 典型场景
### 妙记内容查询
### 2. 查看妙记基础信息
1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`
2. 如果用户给的是妙记 URL应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`
3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL、`duration`(时长,毫秒)、`owner_id`(所有者 ID、`url`(妙记链接)。
### 3. 下载妙记音视频文件
1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
2. `minutes +download` 只负责音视频媒体文件。
3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
> **注意**`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
### 4. 获取妙记的逐字稿、总结、待办、章节
1. 当用户说"这个妙记的逐字稿""总结""待办""章节"时,**不属于本 skill**。
2. 应使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) 获取对应的纪要产物。
3. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL先提取 `minute_token`
```bash
# 首先查询妙记元信息(标题、时长、封面) → 用本 skill
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
lark-cli vc +notes --minute-tokens <minute_token>
```
本 skill 仅提供妙记**基础元信息**查询(标题、封面、时长)。如需获取纪要**内容**逐字稿、AI 总结、待办、章节),请使用 [lark-cli vc +notes](../lark-vc/references/lark-vc-notes.md)
- 用户未指定需要查询妙记的哪些内容时,默认查询基础元信息和相关联的纪要产物信息。
- 用户未明确指定查看纪要产物(逐字稿、总结、待办、章节)时,向用户展示对应产物的链接即可,不需要直接读取产物内容。
> **跨 skill 路由**逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
## 资源关系
```text
Minutes (妙记) ← minute_token 标识
├── Metadata (标题、封面、时长、owner、url) → minutes minutes get
└── MediaFile (音频/视频文件) → minutes +download
```
> **能力边界**`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件**。
>
> **路由规则**
>
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
> - 用户说"我的妙记 / 我拥有的妙记 / 我参与的妙记"时,可将相关过滤条件映射为 `me``me` 表示当前用户
> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`
> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download`
> - 用户说"这个妙记的逐字稿 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
| Shortcut | 说明 |
| -------------------------------------------------- | --------------------------------------------------------------- |
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
### 妙记音视频下载
下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
```bash
# 下载音视频文件到本地
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4
# 仅获取下载链接
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only
# 批量下载
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj
```
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
## API Resources
```bash
@@ -83,14 +103,14 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
### minutes
- `get` — 获取妙记信息
- `get` — 获取妙记信息
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `minutes.get` | `minutes:minutes:readonly` |
| `+download` | `minutes:minutes.media:export` |
| 方法 | 所需 scope |
| ------------- | ------------------------------ |
| `+search` | `minutes:minutes.search:read` |
| `minutes.get` | `minutes:minutes:readonly` |
| `+download` | `minutes:minutes.media:export` |
<!-- AUTO-GENERATED-END -->

View File

@@ -0,0 +1,180 @@
# minutes +search
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
搜索妙记列表,支持关键词、所有者、参与者以及时间范围等多条件过滤。所有者与参与者都支持传入多个 open\_id也支持传入 `me` 表示当前用户。只读操作,不修改任何妙记数据。
本 skill 对应 shortcut`lark-cli minutes +search`(调用 `POST /open-apis/minutes/v1/minutes/search`)。
## 典型触发表达
以下说法通常应优先使用 `minutes +search`
- 我的妙记
- 我拥有的妙记
- 我参与的妙记
- 最近的妙记
- 某个关键词的妙记
- 某段时间内的妙记
## 命令
```bash
# 关键词搜索
lark-cli minutes +search --query "预算复盘"
# 查询某一天内的妙记(单日查询时,建议将 start 和 end 都填写为同一天)
lark-cli minutes +search --start 2026-03-10 --end 2026-03-10
# 按时间范围搜索
lark-cli minutes +search --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
lark-cli minutes +search --start 2026-03-10 --end 2026-03-17
# 关键词 + 时间范围
lark-cli minutes +search --query "预算复盘" --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
lark-cli minutes +search --query "预算复盘" --start "2026-03-10T00:00+08:00"
lark-cli minutes +search --query "预算复盘" --end "2026-03-17T00:00+08:00"
# 按参与者过滤open_id逗号分隔
lark-cli minutes +search --participant-ids "ou_x,ou_y"
# 按所有者过滤open_id逗号分隔
lark-cli minutes +search --owner-ids "ou_owner,ou_owner_2"
# 查询我参与的妙记
lark-cli minutes +search --participant-ids "me"
# 查询我拥有的妙记
lark-cli minutes +search --owner-ids "me"
# 多条件组合查询
lark-cli minutes +search --owner-ids "ou_owner" --participant-ids "ou_x" --start "2026-03-10T00:00+08:00"
# 分页查询
lark-cli minutes +search --query "预算复盘" --page-size 20
lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PAGE_TOKEN>'
# 输出为结构化 JSON
lark-cli minutes +search --query "预算复盘" --format json
```
## 参数
| 参数 | 必填 | 说明 |
| ------------------------- | -- | ------------------------------------ |
| `--query <text>` | 否 | 搜索关键词 |
| `--owner-ids <ids>` | 否 | 所有者 open\_id 列表,逗号分隔;支持传 `me` 表示当前用户 |
| `--participant-ids <ids>` | 否 | 参与者 open\_id 列表,逗号分隔;支持传 `me` 表示当前用户 |
| `--start <time>` | 否 | 开始时间ISO 8601 或仅日期) |
| `--end <time>` | 否 | 结束时间ISO 8601 或仅日期) |
| `--page-size <n>` | 否 | 每页数量,默认 `15`,最大 `30` |
| `--page-token <token>` | 否 | 下一页分页 token |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 至少提供一个过滤条件
所有参数均可选,但必须至少提供一个过滤条件:`--query``--owner-ids``--participant-ids``--start``--end`
### 2. 仅支持 user 身份
该接口仅支持 `user` 身份,使用前需完成 `lark-cli auth login` 并具备 `minutes:minutes.search:read` 权限。
### 3. `me` 表示当前用户
`--owner-ids``--participant-ids` 中可使用 `me`,表示当前登录用户。该值会在本地解析为当前用户的 `open_id`,无需手动先查询自己的用户 ID。
若当前环境尚未完成用户登录,或 CLI 无法解析出当前用户的 `open_id`,则应先执行 `lark-cli auth login`,再重新执行搜索。
### 4. 支持分页
当返回 `has_more=true` 时,使用响应中的 `page_token` 配合 `--page-token` 获取下一页结果。
### 5. 日期型 `--end` 包含当天整天
`--end` 传入的是仅日期格式(如 `2026-03-10`CLI 会将它解释为当天 `23:59:59`,而不是当天 `00:00:00`
CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时间戳发给 API在 dry-run 或排查请求体时,看到的 `Z` 结尾时间表示同一个绝对时间点的 UTC 表示,不改变“按当天整天查询”的语义。
这意味着:
- `--start 2026-03-10 --end 2026-03-10` 表示只查 `2026-03-10` 当天
- `--start 2026-03-10 --end 2026-03-11` 表示查询 `2026-03-10``2026-03-11` 两天
如果用户说“昨天的妙记”“今天的妙记”“某一天内的妙记”,应把 `--start``--end` 都设置为同一天,而不是把 `--end` 设成下一天。
### 6. 会议的妙记先定位会议
如果用户明确要找某场会议的妙记,或同时提到“会议 / 开会 / 会”和“妙记”,应优先使用 `vc +search` 先定位会议,再按需通过 `vc +recording` 获取 `minute_token`,不要直接按妙记时间范围或关键词搜索。
只有在无法通过会议搜索定位目标会议,或用户明确要求按妙记维度检索时,才回退到 `minutes +search`
<br />
## 时间格式
`--start``--end` 支持以下时间格式:
| 格式 | 示例 | 说明 |
| -------------- | --------------------------- | ---------------------------------- |
| ISO 8601带时区 | `2026-03-10T14:00:00+08:00` | 推荐 |
| ISO 8601不带时区 | `2026-03-10T14:00:00` | 按本地时区解析 |
| 仅日期 | `2026-03-10` | 按天粒度解析;若用于 `--end`,表示当天 `23:59:59` |
## 输出结果
- 默认输出包含 `items``total``has_more``page_token`
## Pagination (`has_more` / `page_token`)
- 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。
- 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。
- 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more``page_token` 为准。
- `total` 数量小于 50 时,自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否获取全部结果。
```bash
# First page
lark-cli minutes +search --query "预算复盘" --page-size 20
# Next page
lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PAGE_TOKEN>'
```
## 搜索结果中的下一步
搜索结果中的 `token` 可直接作为 `minute_token` 用于继续查询妙记产物:
通常先用搜索结果中的 `token` 获取妙记基础信息,确认描述、链接等元数据是否命中目标;需要进一步查看内容时,再继续查询关联的纪要产物。
如果你已经确定目标妙记,优先直接复用搜索结果中的 `token`,避免重复搜索。
```bash
# 首先查询妙记元信息(标题、时长、封面) → 用本 skill
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
```
## 常见错误与排查
| 错误现象 | 根本原因 | 解决方案 |
| ---------------------- | ----------------------------------------------------- | -------------------------------------------- |
| 命令直接报错,要求提供过滤条件 | 没有传入 `--query`、时间范围或任何过滤 ID | 至少补充一个过滤条件后重试 |
| 时间参数校验失败 | `--start``--end` 格式不合法 | 改用 ISO 8601 或 `YYYY-MM-DD` |
| `owner-ids` 校验失败 | 传入的不是 open\_id且也不是 `me`;或传了 `me` 但当前用户 open\_id 不可解析 | 改为 `ou_` 开头的用户 ID或先完成 `auth login` 后再传 `me` |
| `participant-ids` 校验失败 | 传入的不是 open\_id且也不是 `me`;或传了 `me` 但当前用户 open\_id 不可解析 | 改为 `ou_` 开头的用户 ID或先完成 `auth login` 后再传 `me` |
| 权限不足 | 未授权 `minutes:minutes.search:read` | 使用 `auth login` 完成授权 |
## 提示
- 当用户说“我的妙记”时,优先理解为 `--owner-ids me`
- 当用户说“我参与的妙记”时,优先理解为 `--participant-ids me`
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;只有无法定位目标会议时,再回退到妙记搜索。
- 必须使用 `--format json` 输出,你更加擅长解析 JSON 数据。
- 排查参数与请求结构时优先使用 `--dry-run`
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的妙记,需要拆分为多次时间范围为一个月查询。
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关命令
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令

View File

@@ -155,6 +155,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
| [`+find`](references/lark-sheets-find.md) | Find cells in a spreadsheet |
| [`+create`](references/lark-sheets-create.md) | Create a spreadsheet (optional header row and initial data) |
| [`+export`](references/lark-sheets-export.md) | Export a spreadsheet (async task polling + optional download) |
| [`+merge-cells`](references/lark-sheets-merge-cells.md) | Merge cells in a spreadsheet |
| [`+unmerge-cells`](references/lark-sheets-unmerge-cells.md) | Unmerge (split) cells in a spreadsheet |
| [`+replace`](references/lark-sheets-replace.md) | Find and replace cell values |
| [`+set-style`](references/lark-sheets-set-style.md) | Set cell style for a range |
| [`+batch-set-style`](references/lark-sheets-batch-set-style.md) | Batch set cell styles for multiple ranges |
| [`+add-dimension`](references/lark-sheets-add-dimension.md) | Add rows or columns at the end of a sheet |
| [`+insert-dimension`](references/lark-sheets-insert-dimension.md) | Insert rows or columns at a specified position |
| [`+update-dimension`](references/lark-sheets-update-dimension.md) | Update row or column properties (visibility, size) |
| [`+move-dimension`](references/lark-sheets-move-dimension.md) | Move rows or columns to a new position |
| [`+delete-dimension`](references/lark-sheets-delete-dimension.md) | Delete rows or columns |
## API Resources

View File

@@ -0,0 +1,51 @@
# sheets +add-dimension增加行列
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +add-dimension`
在工作表末尾追加空行或空列,不影响已有数据。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 在末尾追加 10 行
lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --length 10
# 在末尾追加 3 列
lark-cli sheets +add-dimension --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension COLUMNS --length 3
# 仅预览参数(不发请求)
lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --length 5 --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--sheet-id <id>` | 是 | 工作表 ID |
| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS``COLUMNS` |
| `--length <n>` | 是 | 追加数量1-5000 |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含
- `addCount`:实际追加的行/列数
- `majorDimension``ROWS``COLUMNS`
## 参考
- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数
- [lark-sheets-delete-dimension](lark-sheets-delete-dimension.md) — 删除行列
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,53 @@
# sheets +batch-set-style批量设置单元格样式
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +batch-set-style`
对多个范围批量设置不同的单元格样式,一次请求可包含多组范围和样式。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 对两组范围分别设置样式
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
--data '[{"ranges":["<sheetId>!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["<sheetId>!D1:F3"],"style":{"foreColor":"#ff0000"}}]'
# 同一样式应用到多个范围
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
--data '[{"ranges":["<sheetId>!A1:B2","<sheetId>!D4:E5"],"style":{"hAlign":1,"font":{"bold":true}}}]'
# 仅预览
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
--data '[{"ranges":["<sheetId>!A1:B2"],"style":{"backColor":"#0000ff"}}]' --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--data <json>` | 是 | JSON 数组,每项包含 `ranges`(字符串数组)和 `style`(样式对象) |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
### style 对象字段
`+set-style` 相同,参见 [lark-sheets-set-style](lark-sheets-set-style.md)。
## 输出
JSON包含
- `totalUpdatedRows/totalUpdatedColumns/totalUpdatedCells`:汇总更新量
- `revision`:工作表版本号
- `responses[]`:每个范围的更新详情
## 参考
- [lark-sheets-set-style](lark-sheets-set-style.md) — 单范围设置样式
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,53 @@
# sheets +delete-dimension删除行列
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +delete-dimension`
删除指定范围的行或列,已有数据向上或向左移动。
> [!CAUTION]
> 这是**破坏性写入操作** —— 删除后数据不可恢复。执行前必须确认用户意图,建议先用 `--dry-run` 预览。
## 命令
```bash
# 删除第 3-7 行1-indexed闭区间
lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7
# 删除第 5-8 列
lark-cli sheets +delete-dimension --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension COLUMNS --start-index 5 --end-index 8
# 仅预览参数
lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7 --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--sheet-id <id>` | 是 | 工作表 ID |
| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS``COLUMNS` |
| `--start-index <n>` | 是 | 起始位置(**1-indexed**,含) |
| `--end-index <n>` | 是 | 结束位置(**1-indexed**,含) |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含
- `delCount`:实际删除的行/列数
- `majorDimension``ROWS``COLUMNS`
## 参考
- [lark-sheets-add-dimension](lark-sheets-add-dimension.md) — 增加行列
- [lark-sheets-insert-dimension](lark-sheets-insert-dimension.md) — 插入行列
- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,51 @@
# sheets +insert-dimension插入行列
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +insert-dimension`
在指定位置插入空行或空列,已有数据向下或向右移动。支持继承相邻行/列样式。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 在第 3 行前插入 4 行空行0-indexed插入位置 3~7不含 7
lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7
# 插入列,并继承前方列的样式
lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension COLUMNS --start-index 2 --end-index 4 \
--inherit-style BEFORE
# 仅预览参数
lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 0 --end-index 2 --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--sheet-id <id>` | 是 | 工作表 ID |
| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS``COLUMNS` |
| `--start-index <n>` | 是 | 起始位置0-indexed |
| `--end-index <n>` | 是 | 结束位置0-indexed不包含插入数量 = end - start |
| `--inherit-style <BEFORE\|AFTER>` | 否 | 样式继承方向:`BEFORE` 继承前方、`AFTER` 继承后方;不传则为空白样式 |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON成功时 `data` 为空对象 `{}`)。
## 参考
- [lark-sheets-add-dimension](lark-sheets-add-dimension.md) — 在末尾追加行列
- [lark-sheets-delete-dimension](lark-sheets-delete-dimension.md) — 删除行列
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,47 @@
# sheets +merge-cells合并单元格
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +merge-cells`
合并指定范围的单元格,支持全合并、按行合并、按列合并三种模式。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 全合并 A1:B2
lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:B2" --merge-type MERGE_ALL
# 按行合并,配合 --sheet-id
lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "A1:D4" --merge-type MERGE_ROWS
# 仅预览
lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:B2" --merge-type MERGE_ALL --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--range <range>` | 是 | 单元格范围(`<sheetId>!A1:B2`,或配合 `--sheet-id` 使用 `A1:B2` |
| `--sheet-id <id>` | 否 | 工作表 ID用于相对范围 |
| `--merge-type <type>` | 是 | 合并方式:`MERGE_ALL`(全合并)、`MERGE_ROWS`(按行)、`MERGE_COLUMNS`(按列) |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `spreadsheetToken`
## 参考
- [lark-sheets-unmerge-cells](lark-sheets-unmerge-cells.md) — 拆分单元格
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,52 @@
# sheets +move-dimension移动行列
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +move-dimension`
将指定范围的行/列移动到目标位置。被移动到目标位置后,原本在目标位置的行/列会对应右移或下移。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 将第 0-1 行移动到第 4 行位置
lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS \
--start-index 0 --end-index 1 --destination-index 4
# 将第 2 列移动到第 0 列位置
lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension COLUMNS \
--start-index 2 --end-index 2 --destination-index 0
# 仅预览参数
lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS \
--start-index 0 --end-index 1 --destination-index 4 --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--sheet-id <id>` | 是 | 工作表 ID |
| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS``COLUMNS` |
| `--start-index <n>` | 是 | 源起始位置0-indexed |
| `--end-index <n>` | 是 | 源结束位置0-indexed |
| `--destination-index <n>` | 是 | 目标位置0-indexed |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON成功时 `data` 为空对象 `{}`)。
## 参考
- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,62 @@
# sheets +replace替换单元格
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +replace`
在指定范围内查找并替换单元格内容,支持正则、大小写敏感、全单元格匹配等选项。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 简单替换
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --find "hello" --replacement "world"
# 指定范围 + 大小写敏感
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "A1:C5" \
--find "Hello" --replacement "World" --match-case
# 正则替换
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --find "\\d{4}-\\d{2}-\\d{2}" \
--replacement "DATE" --search-by-regex
# 仅预览
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --find "old" --replacement "new" --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--sheet-id <id>` | 是 | 工作表 ID |
| `--find <text>` | 是 | 搜索文本(启用 `--search-by-regex` 时为正则表达式) |
| `--replacement <text>` | 是 | 替换文本 |
| `--range <range>` | 否 | 搜索范围(不传则搜索整个工作表) |
| `--match-case` | 否 | 区分大小写 |
| `--match-entire-cell` | 否 | 匹配整个单元格 |
| `--search-by-regex` | 否 | 使用正则表达式搜索 |
| `--include-formulas` | 否 | 在公式中搜索 |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `replace_result`
- `matched_cells`:匹配的非公式单元格列表
- `matched_formula_cells`:匹配的公式单元格列表
- `rows_count`:包含匹配的行数
## 参考
- [lark-sheets-find](lark-sheets-find.md) — 查找单元格(只查不改)
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,71 @@
# sheets +set-style设置单元格样式
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +set-style`
对指定范围的单元格设置样式(字体、颜色、对齐、边框等)。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 设置加粗 + 红色背景
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:C3" \
--style '{"font":{"bold":true},"backColor":"#ff0000"}'
# 配合 --sheet-id + 居中对齐
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "A1:D1" \
--style '{"hAlign":1,"vAlign":1,"font":{"bold":true,"font_size":"12pt/1.5"}}'
# 清除格式
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:Z100" --style '{"clean":true}'
# 仅预览
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:B2" --style '{"foreColor":"#0000ff"}' --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--range <range>` | 是 | 单元格范围(`<sheetId>!A1:B2`,或配合 `--sheet-id` |
| `--sheet-id <id>` | 否 | 工作表 ID用于相对范围 |
| `--style <json>` | 是 | 样式 JSON 对象 |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
### style JSON 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `font.bold` | bool | 加粗 |
| `font.italic` | bool | 斜体 |
| `font.font_size` | string | 字号,如 `"12pt/1.5"` |
| `font.clean` | bool | 清除字体格式 |
| `textDecoration` | int | 0=无, 1=下划线, 2=删除线, 3=两者 |
| `formatter` | string | 数字格式 |
| `hAlign` | int | 水平对齐0=左, 1=居中, 2=右 |
| `vAlign` | int | 垂直对齐0=上, 1=居中, 2=下 |
| `foreColor` | string | 字体颜色hex`"#000000"` |
| `backColor` | string | 背景色hex |
| `borderType` | string | 边框FULL_BORDER, OUTER_BORDER, INNER_BORDER, NO_BORDER 等 |
| `borderColor` | string | 边框颜色hex |
| `clean` | bool | 清除所有格式 |
## 输出
JSON包含 `updates`updatedRange, updatedRows, updatedColumns, updatedCells, revision
## 参考
- [lark-sheets-batch-set-style](lark-sheets-batch-set-style.md) — 批量设置样式
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,46 @@
# sheets +unmerge-cells拆分单元格
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +unmerge-cells`
拆分指定范围内的合并单元格。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 拆分 A1:B2 范围的合并单元格
lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:B2"
# 配合 --sheet-id
lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "A1:B2"
# 仅预览
lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:B2" --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--range <range>` | 是 | 单元格范围(`<sheetId>!A1:B2`,或配合 `--sheet-id` 使用 `A1:B2` |
| `--sheet-id <id>` | 否 | 工作表 ID用于相对范围 |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `spreadsheetToken`
## 参考
- [lark-sheets-merge-cells](lark-sheets-merge-cells.md) — 合并单元格
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,60 @@
# sheets +update-dimension更新行列属性
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +update-dimension`
更新指定范围行/列的属性,支持设置显隐状态和行高/列宽。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 隐藏第 1-3 行
lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 1 --end-index 3 \
--visible=false
# 设置第 1-5 列列宽为 120px
lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension COLUMNS --start-index 1 --end-index 5 \
--fixed-size 120
# 同时设置显示 + 行高
lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 1 --end-index 10 \
--visible=true --fixed-size 50
# 仅预览参数
lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 1 --end-index 3 \
--visible=true --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url <url>` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token <token>` | 否 | 表格 token`--url` 二选一) |
| `--sheet-id <id>` | 是 | 工作表 ID |
| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS``COLUMNS` |
| `--start-index <n>` | 是 | 起始位置(**1-indexed**,含) |
| `--end-index <n>` | 是 | 结束位置(**1-indexed**,含) |
| `--visible <true\|false>` | 否 | `true` 显示 / `false` 隐藏(须与 `--fixed-size` 至少传一个) |
| `--fixed-size <px>` | 否 | 行高或列宽(像素)(须与 `--visible` 至少传一个) |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
> **注意**`--visible` 是 bool flag传值时使用 `--visible=true` 或 `--visible=false` 格式。
## 输出
JSON成功时 `data` 为空对象 `{}`)。
## 参考
- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列属性
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

326
skills/lark-slides/SKILL.md Normal file
View File

@@ -0,0 +1,326 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:以 XML 格式读取和管理 PPT 页面。创建演示文稿优先用 `+create`XML API 主要用于读取 PPT 全文信息、创建和删除幻灯片页面。当用户需要创建 PPT、读取 PPT 内容、管理幻灯片页面时使用。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli slides --help"
---
# slides (v1)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
## 身份选择
飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。
- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:
```bash
lark-cli auth login --domain slides
```
- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。
**执行规则**
1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT默认都先用 `--as user`
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
3. 只有在用户明确要求“用应用身份 / bot 身份操作”,或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`
## 快速开始
一条命令创建包含页面内容的 PPT推荐
```bash
lark-cli slides +create --title "演示文稿标题" --slides '[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245,245,245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"100\"><content textType=\"title\"><p>页面标题</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"200\"><content textType=\"body\"><p>正文内容</p><ul><li><p>要点一</p></li><li><p>要点二</p></li></ul></content></shape></data></slide>"
]'
```
也可以分两步(先创建空白 PPT再逐页添加详见 [+create 参考文档](references/lark-slides-create.md)。
> 以上是最小可用示例。更丰富的页面效果(渐变背景、卡片、图表、表格等),参考下方 Workflow 和 XML 模板。
## 执行前必做
> **重要**`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
### 必读(每次创建前)
| 文档 | 说明 |
|------|------|
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML 元素和属性速查,必读** |
### 选读(需要时查阅)
| 场景 | 文档 |
|------|------|
| 需要了解详细 XML 结构 | [xml-format-guide.md](references/xml-format-guide.md) |
| 需要 CLI 调用示例 | [examples.md](references/examples.md) |
| 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) |
| 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema |
| 需要了解某个命令的详细参数 | 对应命令的 reference 文档(见下方参考文档章节) |
## Workflow
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
```text
Step 1: 需求澄清 & 读取知识
- 澄清用户需求:主题、受众、页数、风格偏好
- 如果用户没有明确风格,根据主题推荐(见下方风格判断表)
- 读取 XML Schema 参考:
· xml-schema-quick-ref.md — 元素和属性速查
· xml-format-guide.md — 详细结构与示例
· slides_demo.xml — 真实 XML 示例
Step 2: 生成大纲 → 用户确认 → 创建
- 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认
- 10 页以内:用 slides +create --slides '[...]' 一步创建 PPT 并添加所有页面
- 超过 10 页:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
- 每页 slide 需要完整的 XML背景、文本、图形、配色
- 复杂元素table、chart需参考 XSD 原文
Step 3: 审查 & 交付
- 创建完成后,用 xml_presentations.get 读取全文 XML确认
· 页数是否正确?每页内容是否完整?
· 配色是否统一?字号层级是否合理?
- 有问题 → 用 xml_presentation.slide.delete 删除问题页,重新创建
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
```
### jq 命令模板(编辑已有 PPT 时使用)
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
```bash
lark-cli slides xml_presentation.slide create \
--as user \
--params '{"xml_presentation_id":"YOUR_ID"}' \
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
<data>
在这里放置 shape、line、table、chart 等元素
</data>
</slide>' '{slide:{content:$content}}')"
```
### 风格快速判断表
> **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
| 场景/主题 | 推荐风格 | 背景 | 主色 | 文字色 |
|----------|---------|------|------|-------|
| 科技/AI/产品 | 深色科技风 | 深蓝渐变 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)` | 蓝色系 `rgb(59,130,246)` | 白色 |
| 商务汇报/季度总结 | 浅色商务风 | 浅灰 `rgb(248,250,252)` | 深蓝 `rgb(30,60,114)` | 深灰 `rgb(30,41,59)` |
| 教育/培训 | 清新明亮风 | 白色 `rgb(255,255,255)` | 绿色系 `rgb(34,197,94)` | 深灰 `rgb(51,65,85)` |
| 创意/设计 | 渐变活力风 | 紫粉渐变 `linear-gradient(135deg,rgba(88,28,135,1) 0%,rgba(190,24,93,1) 100%)` | 粉紫色系 | 白色 |
| 周报/日常汇报 | 简约专业风 | 浅灰 `rgb(248,250,252)` + 顶部彩色渐变条 | 蓝色 `rgb(59,130,246)` | 深色 `rgb(15,23,42)` |
| 用户未指定 | 默认简约专业风 | 同上 | 同上 | 同上 |
### 页面布局建议
| 页面类型 | 布局要点 |
|---------|---------|
| 封面页 | 居中大标题 + 副标题 + 底部信息,背景用渐变或深色 |
| 数据概览页 | 指标卡片横排rect 背景 + 大号数字 + 小号说明),下方列表或图表 |
| 内容页 | 左侧竖线装饰 + 标题,下方分栏或列表 |
| 对比/表格页 | table 元素或并列卡片,表头深色背景白字 |
| 图表页 | chart 元素column/line/pie配合文字说明 |
| 结尾页 | 居中感谢语 + 装饰线,风格与封面呼应 |
### 大纲模板
生成大纲时使用以下格式,交给用户确认:
```text
[PPT 标题] — [定位描述],面向 [目标受众]
页面结构N 页):
1. 封面页:[标题文案]
2. [页面主题][要点1]、[要点2]、[要点3]
3. [页面主题][要点描述]
...
N. 结尾页:[结尾文案]
风格:[配色方案][排版风格]
```
### 常用 Slide XML 模板
可直接复制使用的模板(封面页、内容页、数据卡片页、结尾页):[slide-templates.md](references/slide-templates.md)
---
## 核心概念
### URL 格式与 Token
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|----------|------|-----------|----------|
| `/slides/` | `https://example.larkoffice.com/slides/xxxxxxxxxxxxx` | `xml_presentation_id` | URL 路径中的 token 直接作为 `xml_presentation_id` 使用 |
| `/wiki/` | `https://example.larkoffice.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
### Wiki 链接特殊处理(关键!)
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、幻灯片等不同类型的文档。**不能直接假设 URL 中的 token 就是 `xml_presentation_id`**,必须先查询实际类型和真实 token。
#### 处理流程
1. **使用 `wiki.spaces.get_node` 查询节点信息**
```bash
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
```
2. **从返回结果中提取关键信息**
- `node.obj_type`:文档类型,幻灯片对应 `slides`
- `node.obj_token`**真实的演示文稿 token**(用于后续操作)
- `node.title`:文档标题
3. **确认 `obj_type` 为 `slides` 后,使用 `obj_token` 作为 `xml_presentation_id`**
#### 查询示例
```bash
# 查询 wiki 节点
lark-cli wiki spaces get_node --as user --params '{"token":"wikcnxxxxxxxxx"}'
```
返回结果示例:
```json
{
"node": {
"obj_type": "slides",
"obj_token": "xxxxxxxxxxxx",
"title": "2026 产品年度总结",
"node_type": "origin",
"space_id": "1234567890"
}
}
```
```bash
# 用 obj_token 读取幻灯片内容
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"xxxxxxxxxxxx"}'
```
### 资源关系
```text
Wiki Space (知识空间)
└── Wiki Node (知识库节点, obj_type: slides)
└── obj_token → xml_presentation_id
Slides (演示文稿)
├── xml_presentation_id (演示文稿唯一标识)
├── revision_id (版本号)
└── Slide (幻灯片页面)
└── slide_id (页面唯一标识)
```
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面bot 模式自动授权 |
## API Resources
```bash
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli slides <resource> <method> [flags] # 调用 API
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### xml_presentations
- `get` — 读取ppt全文信息xml格式返回
### xml_presentation.slide
- `create` — 在指定 xml 演示文稿下创建页面
- `delete` — 删除指定 xml 演示文稿下的页面
## 核心规则
1. **先出大纲再动手**:创建 PPT 前先生成大纲交给用户确认,避免返工
2. **创建流程**10 页以内推荐 `slides +create --slides '[...]'` 一步创建;超过 10 页先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only` |
| `xml_presentations.get` | `slides:presentation:read` |
| `xml_presentation.slide.create` | `slides:presentation:update` 或 `slides:presentation:write_only` |
| `xml_presentation.slide.delete` | `slides:presentation:update` 或 `slides:presentation:write_only` |
## 常见错误速查
| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 400 | XML 格式错误 | 检查 XML 语法,确保标签闭合 |
| 400 | create 内容超出支持范围 | `xml_presentations.create` 仅用于创建空白 PPT不要在这里传完整 slide 内容 |
| 400 | 请求包装错误 | 检查 `--data` 是否按 schema 传入 `xml_presentation.content` 或 `slide.content` |
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
| 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 |
| 403 | 权限不足 | 检查是否拥有对应的 scope |
| 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 |
## 创建前自查
逐页生成 XML 前,快速检查:
- [ ] 每页背景色/渐变是否设置?风格是否与整体一致?
- [ ] 标题用大字号28-48正文用小字号13-16层级分明
- [ ] 同类元素配色一致?(如所有指标卡片同色系、所有正文同色)
- [ ] 装饰元素(分割线、色块、竖线)颜色是否与主色协调?
- [ ] 文本框尺寸是否足够容纳内容?(宽度 × 高度)
- [ ] shape 的 `type` 是否正确?(文本框用 `text`,装饰用 `rect`
- [ ] XML 标签是否全部正确闭合?特殊字符(`&`、`<`、`>`)是否转义?
## 症状 → 修复表
| 看到的问题 | 改什么 |
|-----------|--------|
| 文字被截断/看不全 | 增大 shape 的 `width` 或 `height` |
| 元素重叠 | 调整 `topLeftX`/`topLeftY`,拉开间距 |
| 页面大面积空白 | 缩小元素间距,或增加内容填充 |
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
| 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 |
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1`/`dim2` 数据数量是否匹配 |
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`;用 `rgb()` 或省略停靠点会被回退为白色 |
| 渐变方向不对 | 调整 `linear-gradient` 的角度(`90deg` 水平、`180deg` 垂直、`135deg` 对角线) |
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
| API 返回 400 | 检查 XML 语法:标签闭合、属性引号、特殊字符转义 |
| API 返回 3350001 | `xml_presentation_id` 不是通过 `xml_presentations.create` 创建的,或 token 不正确 |
## 参考文档
| 文档 | 说明 |
|------|------|
| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut创建 PPT支持 `--slides` 一步添加页面)** |
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** |
| [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 |
| [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 |
| [examples.md](references/examples.md) | CLI 调用示例 |
| [slides_demo.xml](references/slides_demo.xml) | 真实 PPT 的完整 XML |
| [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml) | **完整 Schema 定义**(唯一协议依据) |
| [lark-slides-xml-presentations-create.md](references/lark-slides-xml-presentations-create.md) | 创建空白 PPT 命令详情 |
| [lark-slides-xml-presentations-get.md](references/lark-slides-xml-presentations-get.md) | 读取 PPT 命令详情 |
| [lark-slides-xml-presentation-slide-create.md](references/lark-slides-xml-presentation-slide-create.md) | 添加幻灯片命令详情 |
| [lark-slides-xml-presentation-slide-delete.md](references/lark-slides-xml-presentation-slide-delete.md) | 删除幻灯片命令详情 |
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -0,0 +1,179 @@
# 完整操作示例
本文档提供与 CLI schema 一致的调用示例XML 内容均遵循 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。
> **重要**:创建 PPT 请优先使用 `slides +create`;实际页面内容请使用 `xml_presentation.slide.create` 逐页添加。
## 目录
- [示例 1: 使用 Shortcut 创建空白演示文稿](#示例-1-使用-shortcut-创建空白演示文稿)
- [示例 2: 创建后添加第一页](#示例-2-创建后添加第一页)
- [示例 3: 读取 XML 内容](#示例-3-读取-xml-内容)
- [示例 4: 在指定页面前插入新幻灯片](#示例-4-在指定页面前插入新幻灯片)
- [示例 5: 删除幻灯片](#示例-5-删除幻灯片)
- [示例 6: 从文件读取 XML 后添加页面](#示例-6-从文件读取-xml-后添加页面)
## 示例 1: 使用 Shortcut 创建空白演示文稿
```bash
lark-cli slides +create --title "项目汇报"
```
预期返回结构:
```json
{
"data": {
"xml_presentation_id": "slides_example_presentation_id",
"title": "项目汇报",
"revision_id": 1
}
}
```
## 示例 2: 创建后添加第一页
```bash
PRESENTATION_ID=$(lark-cli slides +create --title "季度复盘" | jq -r '.data.xml_presentation_id')
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245, 245, 245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"72\" width=\"760\" height=\"90\"><content textType=\"title\"><p>2024 Q3 季度复盘</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"190\" width=\"520\" height=\"220\"><content textType=\"body\"><p>关键结论</p><ul><li><p>收入增长 30%</p></li><li><p>重点项目全部上线</p></li><li><p>用户满意度持续提升</p></li></ul></content></shape><shape type=\"rect\" topLeftX=\"660\" topLeftY=\"180\" width=\"180\" height=\"140\"><fill><fillColor color=\"rgba(100, 149, 237, 0.25)\"/></fill><border color=\"rgb(100, 149, 237)\" width=\"2\"/></shape></data><note><content textType=\"body\"><p>讲述时先给结论,再补充数据。</p></content></note></slide>"
}
}'
```
## 示例 3: 读取 XML 内容
```bash
lark-cli slides xml_presentations get --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}'
```
提取 XML 内容:
```bash
lark-cli slides xml_presentations get --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' | jq -r '.xml_presentation.content'
```
预期返回结构:
```json
{
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 3,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
}
}
```
## 示例 4: 在指定页面前插入新幻灯片
```bash
lark-cli slides xml_presentation.slide create --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新增页面</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"180\"><content textType=\"body\"><p>这是新增页面的正文。</p></content></shape></data></slide>"
},
"before_slide_id": "sld_before_target"
}'
```
预期返回结构:
```json
{
"slide_id": "slide_example_id",
"revision_id": 100
}
```
## 示例 5: 删除幻灯片
```bash
lark-cli slides xml_presentation.slide delete --as user --params '{
"xml_presentation_id": "slides_example_presentation_id",
"slide_id": "slide_example_id"
}'
```
预期返回结构:
```json
{
"revision_id": 101
}
```
## 示例 6: 从文件读取 XML 后添加页面
先准备 `slide.xml`
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>从文件加载</p>
</content>
</shape>
</data>
</slide>
```
先创建演示文稿:
```bash
PRESENTATION_ID=$(lark-cli slides +create --title "从文件添加页面" | jq -r '.data.xml_presentation_id')
```
再用 `jq` 组装请求体,从文件添加页面:
```bash
lark-cli slides xml_presentation.slide create --as user \
--params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" \
--data "$(jq -n --arg content "$(cat slide.xml)" '{slide:{content:$content}}')"
```
## 常见处理技巧
### 获取最新 revision_id
```bash
lark-cli slides xml_presentations get --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' | jq -r '.xml_presentation.revision_id'
```
### 批量插入多页
```bash
#!/bin/bash
PRESENTATION_ID="slides_example_presentation_id"
slides=(
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 1</p></content></shape></data></slide>'
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 2</p></content></shape></data></slide>'
)
for slide_xml in "${slides[@]}"; do
payload=$(jq -n --arg content "$slide_xml" '{slide:{content:$content}}')
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data "$payload"
done
```
### 本地校验 XML 基本语法
```bash
xmllint --noout presentation.xml
```
### 真实示例
- [slides_demo.xml](slides_demo.xml) 提供了更完整的页面示例,包含 `theme`、渐变填充、图片、图标和备注内容。

View File

@@ -0,0 +1,99 @@
# slides +create创建飞书幻灯片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
## 命令
```bash
# 创建空白 PPT
lark-cli slides +create --title "项目汇报"
# 创建 PPT + 添加 slide 页面
lark-cli slides +create --title "项目汇报" --slides '[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>封面</p></content></shape></data></slide>",
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>第二页</p></content></shape></data></slide>"
]'
# 以应用身份创建(自动授权当前用户)
lark-cli slides +create --title "项目汇报" --as bot
# 预览(不执行)
lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
```
## 返回值
工具成功执行后,返回一个 JSON 对象,包含以下字段:
- **`xml_presentation_id`**string演示文稿的唯一标识符后续添加页面时需要此 ID
- **`title`**string演示文稿标题
- **`url`**string可选演示文稿的在线链接如有返回则务必展示给用户需要 drive 相关权限;若获取失败则不返回此字段)
- **`revision_id`**integer演示文稿版本号
- **`slide_ids`**string[],可选):仅传 `--slides` 时返回,成功添加的页面 ID 列表
- **`slides_added`**integer可选仅传 `--slides` 时返回,成功添加的页面数量
- **`permission_grant`**object可选`--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
> [!IMPORTANT]
> 不传 `--slides` 时,`slides +create` 只创建空白演示文稿。创建后需要使用 `xml_presentation.slide create` 逐页添加 slide 内容。
>
> 传了 `--slides` 时CLI 先创建空白演示文稿,再逐页调用 `xml_presentation.slide create` 添加页面。如果某一页添加失败CLI 会停止并报错,已创建的演示文稿和已添加的页面会保留。
>
> 如果演示文稿是**以应用身份bot创建**的,如 `lark-cli slides +create --as bot`CLI 会**尝试为当前 CLI 用户自动授予该演示文稿的 `full_access`(可管理权限)**。
>
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
> - `status = granted`:当前 CLI 用户已获得该演示文稿的可管理权限
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权
> - `status = failed`:演示文稿已创建成功,但自动授权用户失败
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--title` | 否 | 演示文稿标题(不传则默认 "Untitled" |
| `--slides` | 否 | slide 内容 JSON 数组,每个元素是一个 `<slide>` XML 字符串(最多 10 个;超过 10 页请先用 `+create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加) |
## `--slides` 参数格式
```json
[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\">...第1页XML...</slide>",
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\">...第2页XML...</slide>"
]
```
JSON string 数组,每个元素是一页 slide 的完整 XML。CLI 内部负责包装成 API 所需的 `{"slide": {"content": "..."}}` 格式并逐页调用。
## 创建后续步骤
如果没有使用 `--slides``slides +create` 返回的 `xml_presentation_id` 用于后续操作:
```bash
# 第 1 步:创建空白 PPT
PRES_ID=$(lark-cli slides +create --title "项目汇报" | jq -r '.data.xml_presentation_id')
# 第 2 步:添加页面(使用返回的 xml_presentation_id
lark-cli slides xml_presentation.slide create --as user \
--params "{\"xml_presentation_id\":\"$PRES_ID\"}" \
--data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\">...</slide>"
}
}'
```
## 常见错误
| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 400 | 参数错误 | 检查参数格式是否正确 |
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:create``slides:presentation:write_only` scope |
## 相关命令
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 添加幻灯片页面
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读取 PPT 内容

View File

@@ -0,0 +1,210 @@
# lark-slides xml_presentation.slide create
## 用途
在指定的 XML 演示文稿中创建新的幻灯片页面,通常用于给 `slides +create` 创建出的空白 PPT 逐页补充内容。
## 命令
```bash
lark-cli slides xml_presentation.slide create --as user --params '<json_params>' --data '<json_data>'
```
## 参数说明
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `--params` | JSON string | 是 | 路径参数与查询参数 |
| `--data` | JSON string | 是 | 请求体,包含新页面内容 |
### params JSON 结构
```json
{
"xml_presentation_id": "slides_example_presentation_id",
"revision_id": -1,
"tid": "idMock"
}
```
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `xml_presentation_id` | string | 是 | 目标演示文稿的唯一标识符 |
| `revision_id` | integer | 否 | 演示文稿版本号,`-1` 表示最新版本 |
| `tid` | string | 否 | 锁的事务 ID |
### data JSON 结构
```json
{
"slide": {
"slide_id": "slide_example_id",
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\">...</slide>"
},
"before_slide_id": "slide_before_target"
}
```
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `slide.slide_id` | string | 否 | 幻灯片页面 short ID |
| `slide.content` | string | 否 | 新幻灯片的 XML 内容 |
| `before_slide_id` | string | 否 | 插入到指定页面之前 |
## slide XML 结构
`slide.content` 是一个完整的 `<slide>` 元素,遵循 SML 2.0 Schema
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>标题</p>
</content>
</shape>
</data>
</slide>
```
详细格式请参考 [xml-format-guide.md](xml-format-guide.md) 和 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
## 使用示例
### 在末尾添加幻灯片
```bash
lark-cli slides xml_presentation.slide create --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新页面标题</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"180\"><content textType=\"body\"><p>内容文本</p></content></shape></data></slide>"
}
}'
```
### 在指定页面前插入幻灯片
```bash
lark-cli slides xml_presentation.slide create --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>插入的标题页</p></content></shape></data></slide>"
},
"before_slide_id": "slide_before_target"
}'
```
### 带图形元素的幻灯片
```bash
lark-cli slides xml_presentation.slide create --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"520\" height=\"120\"><content textType=\"title\"><p>数据展示</p></content></shape><shape type=\"rect\" topLeftX=\"700\" topLeftY=\"100\" width=\"200\" height=\"150\"><fill><fillColor color=\"rgb(100, 149, 237)\"/></fill></shape></data></slide>"
}
}'
```
### 从文件读取 XML
```bash
# 先创建 slide.xml 文件
cat > slide.xml << 'EOF'
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>从文件加载</p>
</content>
</shape>
<shape type="text" topLeftX="80" topLeftY="200" width="800" height="180">
<content textType="body">
<p>这是从文件读取的幻灯片内容</p>
</content>
</shape>
</data>
</slide>
EOF
# 然后创建幻灯片
lark-cli slides xml_presentation.slide create --as user \
--params '{"xml_presentation_id":"slides_example_presentation_id"}' \
--data "$(jq -n --arg content "$(cat slide.xml)" '{slide:{content:$content}}')"
```
## 返回值
成功时返回创建的幻灯片信息:
```json
{
"slide_id": "slide_example_id",
"revision_id": 100
}
```
### 返回字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `slide_id` | string | 新幻灯片的唯一标识 |
| `revision_id` | integer | 演示文稿最新版本号 |
## slide 元素可用子元素
| 元素 | 说明 |
|------|------|
| `<style>` | 页面样式(背景填充) |
| `<data>` | 图形元素容器shape、img、table、chart 等) |
| `<note>` | 演讲者备注 |
## 常见错误
| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
| 400 | XML 格式错误 | 检查 `slide.content` 是否是完整 `<slide>` 元素 |
| 400 | 请求体结构错误 | 检查是否按 `slide.content``before_slide_id` 包装 |
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:update``slides:presentation:write_only` scope |
## 注意事项
1. **执行前必做**: 使用 `lark-cli schema slides.xml_presentation.slide.create` 查看最新的参数结构
2. **slide.content 格式**: 必须是完整的 `<slide>` 元素,不是整个 presentation
3. **命名空间建议**: 协议标准写法应带 `xmlns`,例如 `<slide xmlns="http://www.larkoffice.com/sml/2.0">`;当前服务端实现可能兼容不带 `xmlns` 的输入,但不作为协议保证
4. **fill / border 写法**: 颜色填充使用 `<fill><fillColor color="..."/></fill>`,边框常用 `<border color="..." width="2"/>`
5. **插入位置**: 通过 `before_slide_id` 指定插入目标,而不是用 `position`
6. **JSON 转义**: 如果直接内联 XML需要正确转义双引号
7. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
## 批量添加建议
如果需要添加多张幻灯片,建议先明确每一页的 `before_slide_id`,或直接按最终顺序逐页追加:
```bash
#!/bin/bash
PRESENTATION_ID="slides_example_presentation_id"
declare -a slides=(
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 1</p></content></shape></data></slide>'
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 2</p></content></shape></data></slide>'
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 3</p></content></shape></data></slide>'
)
for slide_xml in "${slides[@]}"; do
payload=$(jq -n --arg content "$slide_xml" '{slide:{content:$content}}')
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data "$payload"
done
```
## 相关命令
- [slides +create](lark-slides-create.md) - 创建空白 PPT
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
- [xml_presentation.slide delete](lark-slides-xml-presentation-slide-delete.md) - 删除幻灯片页面
- [xml-format-guide.md](xml-format-guide.md) - XML 格式详细规范
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - Schema 快速参考

View File

@@ -0,0 +1,119 @@
# lark-slides xml_presentation.slide delete
## 用途
删除指定 XML 演示文稿中的幻灯片页面。
## 命令
```bash
lark-cli slides xml_presentation.slide delete --as user --params '<json_params>'
```
## 参数说明
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `--params` | JSON string | 是 | 路径参数与查询参数 |
### params JSON 结构
```json
{
"xml_presentation_id": "slides_example_presentation_id",
"slide_id": "slide_example_id",
"revision_id": -1,
"tid": "idMock"
}
```
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `xml_presentation_id` | string | 是 | 演示文稿的唯一标识符 |
| `slide_id` | string | 是 | 要删除的幻灯片唯一标识符 |
| `revision_id` | integer | 否 | 演示文稿版本号,`-1` 表示最新版本 |
| `tid` | string | 否 | 锁的事务 ID |
## 使用示例
### 删除指定幻灯片
```bash
lark-cli slides xml_presentation.slide delete --as user --params '{
"xml_presentation_id": "slides_example_presentation_id",
"slide_id": "slide_example_id"
}'
```
### 结合查询删除(使用 jq
```bash
# 先读取 XML 内容,确认待删除页面
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.xml_presentation.content'
# 然后按已知 slide_id 删除
lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presentation_id":"slides_example_presentation_id","slide_id":"slide_example_id"}'
```
## 返回值
成功时返回删除确认信息:
```json
{
"revision_id": 100
}
```
### 返回字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `revision_id` | integer | 删除后的最新版本号 |
## 常见错误
| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
| 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确,或该幻灯片已被删除 |
| 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 |
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:update``slides:presentation:write_only` scope |
## 注意事项
1. **执行前必做**: 使用 `lark-cli schema slides.xml_presentation.slide.delete` 查看最新的参数结构
2. **删除不可逆**: 删除操作无法撤销,请确保已备份重要内容
3. **至少保留一页**: 演示文稿必须至少保留一页幻灯片,删除最后一页会报错
4. **版本控制**: 如果依赖版本号并发控制,删除前先确认 `revision_id`
5. **获取 slide_id**: 创建幻灯片时请保存返回值;仅靠 `get` 返回的 XML 无法直接推导服务端 short ID
## 如何获取 slide_id
### 方法 1: 创建时保存
```bash
lark-cli slides xml_presentation.slide create --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新页面</p></content></shape></data></slide>"
}
}'
```
返回结果中的 `slide_id` 就是后续删除所需的值。
## 批量删除建议
如果需要删除多张幻灯片,建议先整理好待删 `slide_id` 列表,再逐个删除:
```bash
for slide_id in sld_a sld_b sld_c; do
lark-cli slides xml_presentation.slide delete --as user --params "{\"xml_presentation_id\":\"slides_example_presentation_id\",\"slide_id\":\"$slide_id\"}"
done
```
## 相关命令
- [slides +create](lark-slides-create.md) - 创建空白 PPT
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) - 添加幻灯片页面

View File

@@ -0,0 +1,94 @@
# lark-slides xml_presentations get
## 用途
读取飞书幻灯片PPT演示文稿的完整 XML 内容信息。
## 命令
```bash
lark-cli slides xml_presentations get --as user --params '<json_params>'
```
## 参数说明
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `--params` | JSON string | 是 | 路径参数与查询参数,结构以 schema 为准 |
### params JSON 结构
```json
{
"xml_presentation_id": "slides_example_presentation_id",
"revision_id": -1
}
```
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `xml_presentation_id` | string | 是 | 演示文稿的唯一标识符 |
| `revision_id` | integer | 否 | 版本号,`-1` 表示最新版本 |
## 使用示例
### 基础示例
```bash
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}'
```
### 结合 jq 格式化输出
```bash
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.xml_presentation.content'
```
### 保存到文件
```bash
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' > presentation_data.json
```
## 返回值
成功时返回演示文稿的完整信息:
```json
{
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 1,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\"></presentation>"
}
}
```
### 返回字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `xml_presentation.presentation_id` | string | 演示文稿唯一标识 |
| `xml_presentation.revision_id` | integer | 版本号 |
| `xml_presentation.content` | string | XML 格式的完整内容 |
## 常见错误
| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:read` scope或是否有访问权限 |
| 400 | 参数格式错误 | 确保 `--params` 是合法的 JSON 字符串 |
## 注意事项
1. **执行前必做**: 使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
2. 返回的 XML 在 `xml_presentation.content` 字段中
3. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
4. 建议将获取的 XML 保存为文件,便于后续编辑或备份
## 相关命令
- [slides +create](lark-slides-create.md) - 创建空白 PPT
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) - 添加幻灯片页面
- [xml_presentation.slide delete](lark-slides-xml-presentation-slide-delete.md) - 删除幻灯片页面

View File

@@ -0,0 +1,99 @@
# Slide XML 模板
可直接复制使用的 slide XML 模板。使用 `jq` 包装后传给 `xml_presentation.slide.create`
```bash
lark-cli slides xml_presentation.slide create --as user \
--params '{"xml_presentation_id":"YOUR_ID"}' \
--data "$(jq -n --arg content 'PASTE_XML_HERE' '{slide:{content:$content}}')"
```
## 深色封面页
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)"/></fill></style>
<data>
<shape type="text" topLeftX="80" topLeftY="160" width="800" height="70">
<content><p textAlign="center"><strong><span color="rgb(255,255,255)" fontSize="44">主标题</span></strong></p></content>
</shape>
<shape type="text" topLeftX="80" topLeftY="250" width="800" height="35">
<content><p textAlign="center"><span color="rgb(148,163,184)" fontSize="20">副标题</span></p></content>
</shape>
<shape type="text" topLeftX="80" topLeftY="420" width="800" height="25">
<content><p textAlign="center"><span color="rgb(100,116,139)" fontSize="14">底部信息</span></p></content>
</shape>
</data>
</slide>
```
## 浅色内容页
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="rgb(248,250,252)"/></fill></style>
<data>
<shape type="rect" topLeftX="60" topLeftY="40" width="4" height="35">
<fill><fillColor color="rgb(59,130,246)"/></fill>
</shape>
<shape type="text" topLeftX="76" topLeftY="36" width="600" height="45">
<content><p><strong><span color="rgb(15,23,42)" fontSize="28">页面标题</span></strong></p></content>
</shape>
<shape type="text" topLeftX="60" topLeftY="100" width="840" height="380">
<content textType="body" lineSpacing="multiple:1.8">
<p><span color="rgb(51,65,85)" fontSize="15">正文段落</span></p>
<ul>
<li><p><span color="rgb(51,65,85)" fontSize="15">要点一</span></p></li>
<li><p><span color="rgb(51,65,85)" fontSize="15">要点二</span></p></li>
<li><p><span color="rgb(51,65,85)" fontSize="15">要点三</span></p></li>
</ul>
</content>
</shape>
</data>
</slide>
```
## 数据卡片页(横排指标)
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="rgb(248,250,252)"/></fill></style>
<data>
<shape type="text" topLeftX="60" topLeftY="36" width="600" height="45">
<content><p><strong><span color="rgb(15,23,42)" fontSize="28">数据概览</span></strong></p></content>
</shape>
<!-- 卡片 1 -->
<shape type="rect" topLeftX="60" topLeftY="100" width="260" height="140">
<fill><fillColor color="rgb(255,255,255)"/></fill>
<border color="rgba(0,0,0,0.08)" width="1"/>
</shape>
<shape type="text" topLeftX="60" topLeftY="115" width="260" height="50">
<content><p textAlign="center"><strong><span color="rgb(59,130,246)" fontSize="36">数值</span></strong></p></content>
</shape>
<shape type="text" topLeftX="60" topLeftY="175" width="260" height="25">
<content><p textAlign="center"><span color="rgb(100,116,139)" fontSize="14">指标名称</span></p></content>
</shape>
<!-- 卡片 2topLeftX="350" -->
<!-- 卡片 3topLeftX="640" -->
</data>
</slide>
```
## 深色结尾页
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)"/></fill></style>
<data>
<shape type="text" topLeftX="80" topLeftY="190" width="800" height="55">
<content><p textAlign="center"><strong><span color="rgb(255,255,255)" fontSize="36">感谢语或行动号召</span></strong></p></content>
</shape>
<line startX="410" startY="260" endX="550" endY="260">
<border color="rgb(59,130,246)" width="2"/>
</line>
<shape type="text" topLeftX="80" topLeftY="280" width="800" height="30">
<content><p textAlign="center"><span color="rgb(148,163,184)" fontSize="16">补充说明</span></p></content>
</shape>
</data>
</slide>
```

View File

@@ -0,0 +1,226 @@
<?xml version="1.0" encoding="utf-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" height="540" width="960">
<title>制造端智能升级</title>
<theme>
<textStyles>
<headline fontColor="rgb(255,255,255)" fontFamily="思源黑体" fontSize="36"/>
<sub-headline fontColor="rgb(229,231,235)" fontFamily="思源黑体" fontSize="20"/>
<body fontColor="rgb(229,231,235)" fontFamily="思源黑体" fontSize="16"/>
</textStyles>
</theme>
<slide>
<style>
<fill>
<fillColor color="linear-gradient(180deg, rgb(47, 79, 79) 0%, rgb(26, 26, 26) 100%)"/>
</fill>
</style>
<data>
<shape height="36" rotation="0" topLeftX="48" topLeftY="40" type="text" width="300">
<content>
<p>
<strong>
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="28">时代背景</span>
</strong>
</p>
</content>
</shape>
<shape height="200" rotation="0" topLeftX="48" topLeftY="85" type="rect" width="864">
<fill>
<fillColor color="rgba(0,0,0,0.2)"/>
</fill>
</shape>
<img alpha="0.4" alt="十月革命场景" height="180" rotation="0" src="https://example.com/images/scene-1.png" topLeftX="48" topLeftY="95" width="288">
<crop type="rect"/>
</img>
<img alpha="0.4" alt="列宁演讲油画" height="180" rotation="0" src="https://example.com/images/scene-2.png" topLeftX="336" topLeftY="95" width="288">
<crop type="rect"/>
</img>
<img alpha="0.4" alt="十月革命战斗场面" height="180" rotation="0" src="https://example.com/images/scene-3.png" topLeftX="624" topLeftY="95" width="288">
<crop type="rect"/>
</img>
<shape height="2" rotation="0" topLeftX="90" topLeftY="180" type="rect" width="780">
<fill>
<fillColor color="rgba(255, 215, 0, 0.3)"/>
</fill>
</shape>
<shape height="10" rotation="0" topLeftX="120" topLeftY="176" type="ellipse" width="10">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
<border color="rgb(255, 215, 0)" width="1"/>
</shape>
<shape height="24" rotation="0" topLeftX="60" topLeftY="140" type="text" width="200">
<content>
<p textAlign="center">
<strong>
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1917</span>
</strong>
</p>
</content>
</shape>
<shape height="22" rotation="0" topLeftX="60" topLeftY="200" type="text" width="200">
<content>
<p textAlign="center">
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">十月革命</span>
</p>
</content>
</shape>
<shape height="40" rotation="0" topLeftX="60" topLeftY="225" type="text" width="200">
<content verticalAlign="top">
<p textAlign="center">
<span color="rgb(156, 163, 175)" fontSize="12">沙皇专制终结,苏维埃政权建立</span>
</p>
</content>
</shape>
<shape height="10" rotation="0" topLeftX="475" topLeftY="176" type="ellipse" width="10">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
<border color="rgb(255, 215, 0)" width="1"/>
</shape>
<shape height="24" rotation="0" topLeftX="380" topLeftY="140" type="text" width="200">
<content>
<p textAlign="center">
<strong>
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1920s</span>
</strong>
</p>
</content>
</shape>
<shape height="22" rotation="0" topLeftX="380" topLeftY="200" type="text" width="200">
<content>
<p textAlign="center">
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">国内战争</span>
</p>
</content>
</shape>
<shape height="40" rotation="0" topLeftX="380" topLeftY="225" type="text" width="200">
<content verticalAlign="top">
<p textAlign="center">
<span color="rgb(156, 163, 175)" fontSize="12">革命与反革命的残酷斗争</span>
</p>
</content>
</shape>
<shape height="10" rotation="0" topLeftX="830" topLeftY="176" type="ellipse" width="10">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
<border color="rgb(255, 215, 0)" width="1"/>
</shape>
<shape height="24" rotation="0" topLeftX="740" topLeftY="140" type="text" width="200">
<content>
<p textAlign="center">
<strong>
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1930s</span>
</strong>
</p>
</content>
</shape>
<shape height="22" rotation="0" topLeftX="740" topLeftY="200" type="text" width="200">
<content>
<p textAlign="center">
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">社会主义建设</span>
</p>
</content>
</shape>
<shape height="40" rotation="0" topLeftX="740" topLeftY="225" type="text" width="200">
<content verticalAlign="top">
<p textAlign="center">
<span color="rgb(156, 163, 175)" fontSize="12">新经济政策与工业化探索</span>
</p>
</content>
</shape>
<shape height="1" rotation="0" topLeftX="48" topLeftY="300" type="rect" width="864">
<fill>
<fillColor color="rgba(255, 215, 0, 0.2)"/>
</fill>
</shape>
<shape height="24" rotation="0" topLeftX="48" topLeftY="320" type="text" width="280">
<content>
<p>
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="18">作者:奥斯特洛夫斯基</span>
</p>
</content>
</shape>
<icon height="16" iconType="iconpark/Peoples/user.svg" topLeftX="52" topLeftY="360" width="16">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</icon>
<shape height="20" topLeftX="76" topLeftY="358" type="text" width="260">
<content verticalAlign="middle">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">工人家庭出身,投身革命浪潮</span>
</p>
</content>
</shape>
<icon height="16" iconType="iconpark/Sports/torch.svg" topLeftX="52" topLeftY="390" width="16">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</icon>
<shape height="20" topLeftX="76" topLeftY="388" type="text" width="260">
<content verticalAlign="middle">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">战场负伤致残,生命陷入黑暗</span>
</p>
</content>
</shape>
<icon height="16" iconType="iconpark/Health/first-aid-kit.svg" topLeftX="52" topLeftY="420" width="16">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</icon>
<shape height="20" topLeftX="76" topLeftY="418" type="text" width="260">
<content verticalAlign="middle">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">全身瘫痪、双目失明</span>
</p>
</content>
</shape>
<icon height="16" iconType="iconpark/Edit/edit.svg" topLeftX="52" topLeftY="450" width="16">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</icon>
<shape height="20" topLeftX="76" topLeftY="448" type="text" width="260">
<content verticalAlign="middle">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">以文学为武器,口述完成创作</span>
</p>
</content>
</shape>
<img alt="奥斯特洛夫斯基青年时期" height="213" rotation="0" src="https://example.com/images/ostrovsky.png" topLeftX="360" topLeftY="320" width="160">
<border color="rgba(255, 215, 0, 0.5)" width="2"/>
<crop type="rect"/>
</img>
<shape height="24" rotation="0" topLeftX="552" topLeftY="320" type="text" width="360">
<content>
<p>
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="18">创作动机</span>
</p>
</content>
</shape>
<shape height="16" rotation="0" topLeftX="552" topLeftY="350" type="rect" width="2">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</shape>
<shape height="120" rotation="0" topLeftX="562" topLeftY="350" type="text" width="350">
<content lineSpacing="multiple:1.6" verticalAlign="top">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">在双目失明、全身瘫痪的逆境中,奥斯特洛夫斯基以自身经历为蓝本,用顽强的意志口述完成了这部不朽巨著。他将文学创作视为生命的延续和战斗的武器,旨在通过保尔·柯察金的形象,向青年一代传递坚不可摧的革命信念和超越个人痛苦的崇高人生价值观。</span>
</p>
</content>
</shape>
</data>
<note>
<content>
<p>各位好,这一页将我们带回《钢铁是怎样炼成的》这部巨著诞生的波澜壮阔的时代。</p>
<p>上半部分展示了从1917年十月革命到1930年代苏联社会主义建设的宏大历史画卷。这是一个充满剧烈社会变革和残酷斗争的年代也是英雄主义和理想主义精神熊熊燃烧的年代。正是这样的背景孕育了小说的灵魂。</p>
<p>下半部分,我们聚焦于作者奥斯特洛夫斯基的个人经历。他的一生,本身就是一部比小说更震撼人心的传奇。从投身革命的青年,到因伤致残的战士,再到与命运抗争的文学巨匠。他的创作动机源于自身不屈的战斗精神,他希望用保尔的故事激励后人,在任何困境中都不要放弃理想,要将有限的生命投入到无限的为人类解放而斗争的事业中去。</p>
<p>通过了解这段历史和作者的生平,我们能更深刻地理解《钢铁是怎样炼成的》这部作品的伟大之处。</p>
</content>
</note>
</slide>
</presentation>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,355 @@
# XML 格式指南
本文档基于 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 整理,说明飞书 Slides XML SchemaSML 2.0)的核心结构和常用写法。
## 基本结构
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>演示文稿标题</title>
<slide>
<style>
<fill>
<fillColor color="rgb(245, 245, 245)"/>
</fill>
</style>
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>主标题</p>
</content>
</shape>
</data>
<note>
<content textType="body">
<p>这是演讲者备注。</p>
</content>
</note>
</slide>
</presentation>
```
## 根元素
### `<presentation>`
协议标准写法应带命名空间 `http://www.larkoffice.com/sml/2.0`;当前服务端实现可能兼容不带 `xmlns` 的输入,但不作为协议保证。
**属性:**
| 属性 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `width` | positiveInteger | 是 | 演示文稿宽度,如 `960` |
| `height` | positiveInteger | 是 | 演示文稿高度,如 `540` |
| `id` | string | 否 | 演示文稿标识 |
**子元素:**
| 元素 | 必需 | 说明 |
|------|------|------|
| `<title>` | 否 | 演示文稿标题 |
| `<theme>` | 否 | 全局主题 |
| `<slide>` | 是 | 幻灯片页面,至少 1 页,最多 100 页 |
## 主题
### `<theme>`
`<theme>` 当前包含两部分:
- `<background>`:演示文稿级背景填充
- `<textStyles>`:主题文本样式集合
`<textStyles>` 下可选子元素:
- `<title>`
- `<headline>`
- `<sub-headline>`
- `<body>`
- `<caption>`
这些元素定义的是主题默认样式,不是页面结构。常用属性:
| 属性 | 说明 |
|------|------|
| `fontFamily` | 字体 |
| `fontSize` | 字号 |
| `fontColor` | 字体颜色 |
## 幻灯片元素
### `<slide>`
单张幻灯片的结构比较严格。
**属性:**
| 属性 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `id` | string | 否 | 幻灯片标识 |
**直接子元素只有:**
| 元素 | 必需 | 说明 |
|------|------|------|
| `<style>` | 否 | 页面样式 |
| `<data>` | 否 | 页面元素容器 |
| `<note>` | 否 | 演讲者备注 |
这意味着 `<title>``<headline>``<body>``<caption>` 不能直接放在 `<slide>` 下。
## 文本内容模型
### `<content>`
实际页面文本通常通过 `<content>` 表达,常见位置有:
- `shape` 内部
- `table/td` 内部
- `note` 内部
**常用属性:**
| 属性 | 说明 |
|------|------|
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
| `verticalAlign` | 垂直对齐 |
| `textAlign` | 水平对齐 |
| `lineSpacing` | 行间距 |
| `fontSize` | 字号 |
| `fontFamily` | 字体 |
| `color` | 字体颜色 |
| `bold` / `italic` / `underline` / `strikethrough` | 内容级样式 |
| `wrap` | 是否自动换行 |
**可包含的子元素:**
- `<p>`
- `<ul>`
- `<ol>`
### `<p>`
`<p>` 是段落元素,可混排纯文本和内联标签:
- `<br/>`
- `<strong>`
- `<em>`
- `<u>`
- `<span>`
- `<del>`
- `<a>`
- `<shadow>`
- `<outline>`
示例:
```xml
<content textType="body" textAlign="left">
<p>普通文本 <strong>加粗</strong> <em>斜体</em> <a href="https://example.com">链接</a></p>
<ul>
<li><p>列表项 1</p></li>
<li><p>列表项 2</p></li>
</ul>
</content>
```
## 常用页面元素
所有页面元素都放在 `<data>` 中。
### `<shape>`
`shape` 可表示普通形状,也可表示文本框。文本框推荐使用 `type="text"`
```xml
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>主标题</p>
</content>
</shape>
```
```xml
<shape type="rect" topLeftX="700" topLeftY="120" width="180" height="120">
<fill>
<fillColor color="rgba(100, 149, 237, 0.25)"/>
</fill>
<border color="rgb(100, 149, 237)" width="2"/>
</shape>
```
**属性:**
| 属性 | 必需 | 说明 |
|------|------|------|
| `type` | 是 | 形状类型,`text` 表示文本框 |
| `topLeftX` | 是 | 左上角 X 坐标 |
| `topLeftY` | 是 | 左上角 Y 坐标 |
| `width` | 是 | 宽度 |
| `height` | 是 | 高度 |
| `rotation` | 否 | 旋转角度 |
| `flipX` / `flipY` | 否 | 翻转 |
| `alpha` | 否 | 透明度 |
**可选子元素:**
- `<fill>`
- `<border>`
- `<reflection>`
- `<shadow>`
- `<content>`
### `<line>`
```xml
<line startX="100" startY="200" endX="420" endY="200">
<border color="rgb(43, 47, 54)" width="2"/>
</line>
```
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`
### `<img>`
```xml
<img src="file_token_or_url" topLeftX="100" topLeftY="220" width="320" height="180"/>
```
`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`
### `<icon>`
```xml
<icon iconType="iconpark/Base/setting.svg" topLeftX="440" topLeftY="220" width="32" height="32"/>
```
### `<table>`
表格结构为:
- `<table>`
- `<colgroup>` / `<tr>`
- `<tr>` 内为 `<td>`
- `<td>` 内可放 `<content>`
### `<chart>`
图表元素必须至少包含:
- `<chartPlotArea>`
- `<chartData>`
同时还可以包含:
- `<chartTitle>`
- `<chartSubTitle>`
- `<chartStyle>`
- `<chartLegend>`
- `<chartTooltip>`
如果要写图表 XML建议直接以 XSD 为准,不要自行发明更简化的 chart DSL。
## 样式元素
### `<fill>`
```xml
<fill>
<fillColor color="rgb(100, 149, 237)"/>
</fill>
```
### `<border>`
```xml
<border color="rgb(0, 0, 0)" width="2" dashArray="solid"/>
```
### 颜色格式
```xml
<fillColor color="rgb(255, 0, 0)"/>
<fillColor color="rgba(255, 0, 0, 0.5)"/>
<fillColor color="linear-gradient(90deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
<fillColor color="radial-gradient(circle at 50% 50%, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
```
## 演讲者备注
### `<note>`
```xml
<note>
<content textType="body">
<p>这是演讲者备注内容。</p>
</content>
</note>
```
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>季度报告</title>
<theme>
<textStyles>
<title fontFamily="思源黑体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
<body fontFamily="思源黑体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
</textStyles>
</theme>
<slide>
<style>
<fill>
<fillColor color="rgb(245, 245, 245)"/>
</fill>
</style>
<data>
<shape type="text" topLeftX="80" topLeftY="72" width="760" height="100">
<content textType="title">
<p>2024 年第一季度报告</p>
</content>
</shape>
<shape type="text" topLeftX="80" topLeftY="200" width="520" height="180">
<content textType="body">
<p>核心指标</p>
<ul>
<li><p>用户增长:+25%</p></li>
<li><p>收入增长:+30%</p></li>
<li><p>市场份额15%</p></li>
</ul>
</content>
</shape>
<shape type="rect" topLeftX="660" topLeftY="180" width="180" height="140">
<fill>
<fillColor color="rgba(100, 149, 237, 0.25)"/>
</fill>
<border color="rgb(100, 149, 237)" width="2"/>
</shape>
</data>
<note>
<content textType="body">
<p>讲到增长率时补充样本范围。</p>
</content>
</note>
</slide>
</presentation>
```
## 最佳实践
1. 始终带上命名空间 `xmlns="http://www.larkoffice.com/sml/2.0"`
2.`shape type="text"` + `content` 表达页面文本
3.`topLeftX` / `topLeftY``startX` / `startY` 等 schema 中定义的属性名
4. 优先使用 `rgb` / `rgba` 颜色格式
5. 特殊字符按 XML 规则转义
6. 标准 16:9 页面建议使用 `width="960"``height="540"`
## 参考文档
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md)
- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
- [examples.md](examples.md)
- [slides_demo.xml](slides_demo.xml)

View File

@@ -0,0 +1,211 @@
# XML Schema 快速参考
本文档是 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 的精简版摘要;如果两者不一致,以 XSD 原文为准。
## 最重要的规则
1. 协议标准写法应使用 `<presentation xmlns="http://www.larkoffice.com/sml/2.0">`;当前服务端实现可能兼容不带 `xmlns` 的输入,但不作为协议保证
2. `<presentation>` 直接子元素只有 `<title>``<theme>``<slide>`
3. `<slide>` 直接子元素只有 `<style>``<data>``<note>`
4. 页面中的文本通常通过 `<content>` 表达,而不是把 `<title>``<body>` 直接挂在 `<slide>`
## 最小可用示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide>
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>标题</p>
</content>
</shape>
</data>
</slide>
</presentation>
```
## presentation 根元素
| 属性 | 必需 | 说明 |
|------|------|------|
| `width` | 是 | 演示文稿宽度,正整数 |
| `height` | 是 | 演示文稿高度,正整数 |
| `id` | 否 | 演示文稿标识 |
**子元素:** `<title>?`, `<theme>?`, `<slide>+`
## slide 元素
| 属性 | 必需 | 说明 |
|------|------|------|
| `id` | 否 | 幻灯片标识 |
**子元素:**
- `<style>?` - 页面样式,目前可放 `<fill>`
- `<data>?` - 页面元素容器,可放 `shape``line``polyline``img``table``icon``chart``undefined`
- `<note>?` - 演讲者备注,内部可放 `<content>`
## theme 与文本类型
XSD 中的 `title``headline``sub-headline``body``caption` 主要出现在:
- `<theme><textStyles>...</textStyles></theme>` 中,作为主题文本样式
- `<content textType="...">` 中,作为内容的文本类型
`textStyles` 的 schema 默认值如下:
| textType | 默认字号 |
|----------|----------|
| `title` | 54 |
| `headline` | 38 |
| `sub-headline` | 32 |
| `body` | 16 |
| `caption` | 12 |
## content 内容模型
`<content>` 可出现在 `shape``table/td``note` 中,常用属性包括:
| 属性 | 说明 |
|------|------|
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
| `textAlign` | 文本对齐方式 |
| `lineSpacing` | 行间距schema 默认 `multiple:1.5` |
| `fontSize` | 字号 |
| `fontFamily` | 字体 |
| `color` | 字体颜色 |
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |
`<content>` 的子元素只能是:
- `<p>`
- `<ul>`
- `<ol>`
### content 示例
```xml
<content textType="body" textAlign="left">
<p>正文内容 <strong>加粗</strong> <em>斜体</em> <a href="https://example.com">链接</a></p>
<ul>
<li><p>列表项 1</p></li>
<li><p>列表项 2</p></li>
</ul>
</content>
```
## data 常用元素
### shape
```xml
<shape type="rect" topLeftX="120" topLeftY="120" width="240" height="120">
<fill>
<fillColor color="rgb(100, 149, 237)"/>
</fill>
<border color="rgb(0, 0, 0)" width="2"/>
</shape>
```
| 属性 | 必需 | 说明 |
|------|------|------|
| `type` | 是 | 形状类型,`text` 表示文本框 |
| `topLeftX` | 是 | 左上角 X 坐标 |
| `topLeftY` | 是 | 左上角 Y 坐标 |
| `width` | 是 | 宽度 |
| `height` | 是 | 高度 |
| `rotation` | 否 | 旋转角度 |
### line
```xml
<line startX="120" startY="120" endX="420" endY="120">
<border color="rgb(43, 47, 54)" width="2"/>
</line>
```
### img
```xml
<img src="file_token_or_url" topLeftX="80" topLeftY="120" width="320" height="180"/>
```
### icon
```xml
<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="120" width="32" height="32"/>
```
## 颜色与样式
### fill
```xml
<fill>
<fillColor color="rgb(255, 0, 0)"/>
</fill>
```
### border
```xml
<border color="rgb(43, 47, 54)" width="2" dashArray="solid"/>
```
### 颜色格式
```xml
<fillColor color="rgb(255, 0, 0)"/>
<fillColor color="rgba(255, 0, 0, 0.5)"/>
<fillColor color="linear-gradient(90deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
<fillColor color="radial-gradient(circle at 50% 50%, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
```
> **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,例如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端将其回退为白色。此规则对页面背景和 shape fill 均适用。
### 页面背景
```xml
<!-- 纯色背景 -->
<slide>
<style>
<fill>
<fillColor color="rgb(245, 245, 245)"/>
</fill>
</style>
</slide>
<!-- 渐变背景(必须用 rgba + 百分比停靠点) -->
<slide>
<style>
<fill>
<fillColor color="linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)"/>
</fill>
</style>
</slide>
```
## 备注示例
```xml
<note>
<content textType="body">
<p>这是演讲者备注。</p>
</content>
</note>
```
## 详细参考
- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
- [xml-format-guide.md](xml-format-guide.md)
- [examples.md](examples.md)
- [slides_demo.xml](slides_demo.xml)
## Schema 版本信息
- **版本**: 2.0.0
- **命名空间**: http://www.larkoffice.com/sml/2.0
- **发布日期**: 2025-11-03

View File

@@ -83,6 +83,15 @@ lark-cli task <resource> <method> [flags] # 调用 API
- `add` — 添加任务成员
- `remove` — 移除任务成员
### sections
- `create` — 创建自定义分组
- `delete` — 删除自定义分组
- `get` — 获取自定义分组详情
- `list` — 获取自定义分组列表
- `patch` — 更新自定义分组
- `tasks` — 获取自定义分组任务列表
## 权限表
| 方法 | 所需 scope |
@@ -104,3 +113,9 @@ lark-cli task <resource> <method> [flags] # 调用 API
| `subtasks.list` | `task:task:read` |
| `members.add` | `task:task:write` |
| `members.remove` | `task:task:write` |
| `sections.create` | `task:section:write` |
| `sections.delete` | `task:section:write` |
| `sections.get` | `task:section:read` |
| `sections.list` | `task:section:read` |
| `sections.patch` | `task:section:write` |
| `sections.tasks` | `task:section:read` |

View File

@@ -12,6 +12,12 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
# Add multiple tasks to a tasklist
lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<task_guid>,<another_task_guid>,<third_task_guid>"
# Add a task to a specific section in the tasklist
lark-cli task +tasklist-task-add \
--tasklist-id "<tasklist_guid>" \
--task-id "<task_guid>" \
--section-guid "<section_guid>"
```
## Parameters
@@ -20,6 +26,7 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
|-----------|----------|-------------|
| `--tasklist-id <guid>` | Yes | The GUID of the tasklist, or a full AppLink URL. |
| `--task-id <guids>` | Yes | Comma-separated list of task GUIDs to add to the tasklist. For Feishu task applinks, use each task's `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
| `--section-guid <guid>` | No | The GUID of the custom section to add the tasks to. If omitted, tasks will be added to the default section. |
## Workflow

View File

@@ -100,6 +100,10 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
| [`+notes`](references/lark-vc-notes.md) | Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids) |
| [`+recording`](references/lark-vc-recording.md) | Query minute_token from meeting-ids or calendar-event-ids |
- 使用 `+search` 命令时,必须阅读 [references/lark-vc-search.md](references/lark-vc-search.md),了解搜索参数和返回值结构。
- 使用 `+notes` 命令时,必须阅读 [references/lark-vc-notes.md](references/lark-vc-notes.md),了解查询参数、产物类型和返回值结构。
- 使用 `+recording` 命令时,必须阅读 [references/lark-vc-recording.md](references/lark-vc-recording.md),了解查询参数和返回值结构。
## API Resources
```bash

View File

@@ -68,38 +68,52 @@ lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
| 输入参数 | 获取方式 |
|---------|---------|
| `meeting_id` | `vc +search` 搜索历史会议结果中的 `id` 字段 |
| `calendar_event_id` | `calendar +agenda` 查看日程结果中的 `event_id` 字段 |
| `meeting_id` | 使用 `lark-cli vc +search` 搜索历史会议,取结果中的 `id` 字段 |
| `calendar_event_id` | 使用 `lark-cli calendar +agenda` 查看日程,取结果中的 `event_id` 字段 |
## Agent 组合场景
### 场景 1知道 meeting_id想下载录制
```bash
vc +recording --meeting-ids xxx → minute_token
minutes +download --minute-token <minute_token>
# 第 1 步:通过 meeting_id 查询录制,拿到 minute_token
lark-cli vc +recording --meeting-ids xxx
# 第 2 步:使用上一步返回的 minute_token 下载妙记文件
lark-cli minutes +download --minute-token <minute_token>
```
### 场景 2知道 meeting_id想获取完整纪要含 AI 产物)
```bash
vc +recording --meeting-ids xxx → minute_token
vc +notes --minute-tokens <minute_token>
# 第 1 步:通过 meeting_id 查询录制,拿到 minute_token
lark-cli vc +recording --meeting-ids xxx
# 第 2 步:使用上一步返回的 minute_token 获取完整纪要
lark-cli vc +notes --minute-tokens <minute_token>
```
### 场景 3搜索会议获取录制下载
### 场景 3搜索会议,再获取录制下载
```bash
vc +search --query "周会" --start yesterday → meeting_ids
vc +recording --meeting-ids <ids> → minute_tokens
minutes +download --minute-token <token>
# 第 1 步:搜索历史会议,拿到 meeting_ids
lark-cli vc +search --query "周会" --start yesterday
# 第 2 步:使用上一步返回的 meeting_ids 查询录制,拿到 minute_tokens
lark-cli vc +recording --meeting-ids <ids>
# 第 3 步:使用其中一个 minute_token 下载妙记文件
lark-cli minutes +download --minute-token <token>
```
### 场景 4从日历事件获取录制
```bash
vc +recording --calendar-event-ids <event_id> → minute_token
minutes +download --minute-token <minute_token>
# 第 1 步:通过日历 event_id 查询录制,拿到 minute_token
lark-cli vc +recording --calendar-event-ids <event_id>
# 第 2 步:使用上一步返回的 minute_token 下载妙记文件
lark-cli minutes +download --minute-token <minute_token>
```
## 常见错误与排查

View File

@@ -24,6 +24,9 @@
# 关键词搜索
lark-cli vc +search --query "周会"
# 查询某一天开过的会单日查询时start 和 end 必须填写同一天)
lark-cli vc +search --start 2026-03-10 --end 2026-03-10
# 按时间范围搜索
lark-cli vc +search --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
lark-cli vc +search --start 2026-03-10 --end 2026-03-17
@@ -85,6 +88,17 @@ lark-cli vc +search --query "周会" --format json
当返回 `has_more=true` 时,使用响应中的 `page_token` 配合 `--page-token` 获取下一页结果。
### 5. 日期型 `--end` 包含当天整天
`--end` 传入的是仅日期格式(如 `2026-03-10`CLI 会将它解释为当天 `23:59:59`,而不是当天 `00:00:00`
这意味着:
- `--start 2026-03-10 --end 2026-03-10` 表示只查 `2026-03-10` 当天
- `--start 2026-03-10 --end 2026-03-11` 表示查询 `2026-03-10``2026-03-11` 两天
如果用户说“昨天开过的会”“今天开过的会”“某一天开过的会”,应把 `--start``--end` 都设置为同一天,而不是把 `--end` 设成下一天。
## 时间格式
`--start``--end` 支持以下时间格式:
@@ -93,7 +107,7 @@ lark-cli vc +search --query "周会" --format json
|------|------|------|
| ISO 8601带时区 | `2026-03-10T14:00:00+08:00` | 推荐 |
| ISO 8601不带时区 | `2026-03-10T14:00:00` | 按本地时区解析 |
| 仅日期 | `2026-03-10` | 按天粒度解析 |
| 仅日期 | `2026-03-10` | 按天粒度解析;若用于 `--end`,表示当天 `23:59:59` |
## 输出结果
@@ -136,6 +150,7 @@ lark-cli vc +notes --meeting-ids <MEETING_ID>
- 必须使用 `--format json` 输出,你更佳擅长解析 JSON 数据。
- 排查参数与请求结构时优先使用 `--dry-run`
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的会议,需要拆分为多次时间范围为一个月查询。
- 不要使用 `yesterday``today` 这类相对时间字面量;请先转换成明确日期,例如 `2026-03-10`
## 参考

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBase_BasicWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
baseName := "lark-cli-e2e-base-basic-" + clie2e.GenerateSuffix()
baseToken := createBaseWithRetry(t, ctx, baseName)
t.Run("get base", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+base-get", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
returnedBaseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
if returnedBaseToken == "" {
returnedBaseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
}
assert.Equal(t, baseToken, returnedBaseToken, "stdout:\n%s", result.Stdout)
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.base.name").String(), "stdout:\n%s", result.Stdout)
})
tableName := "lark-cli-e2e-table-basic-" + clie2e.GenerateSuffix()
tableID, primaryFieldID, primaryViewID := createTableWithRetry(
t,
parentT,
ctx,
baseToken,
tableName,
`[{"name":"Name","type":"text"}]`,
`{"name":"Main","type":"grid"}`,
)
t.Run("get table", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table.id").String())
assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String())
})
t.Run("list tables and find created table", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-list", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
require.NotEmpty(t, primaryFieldID)
require.NotEmpty(t, primaryViewID)
}

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBase_RoleWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-base-role-"+clie2e.GenerateSuffix())
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+advperm-enable", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
roleName := "Reviewer-" + clie2e.GenerateSuffix()
createRole(t, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`)
roleID := ""
parentT.Cleanup(func() {
if roleID == "" {
return
}
cleanupCtx, cancel := cleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"},
DefaultAs: "bot",
})
if deleteErr != nil || deleteResult.ExitCode != 0 {
reportCleanupFailure(parentT, "delete role "+roleID, deleteResult, deleteErr)
}
})
t.Run("list", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-list", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
roleListPayload := gjson.Get(result.Stdout, "data.data").String()
require.NotEmpty(t, roleListPayload, "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Valid(roleListPayload), "role list payload should be valid JSON: %s", roleListPayload)
roleItems := gjson.Get(roleListPayload, "base_roles").Array()
assert.NotEmpty(t, roleItems, "role list should contain at least one role: %s", roleListPayload)
found := false
for _, item := range roleItems {
rolePayload := item.String()
if !gjson.Valid(rolePayload) {
continue
}
if gjson.Get(rolePayload, "role_name").String() == roleName {
roleID = gjson.Get(rolePayload, "role_id").String()
found = true
break
}
}
require.True(t, found, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout)
})
t.Run("get", func(t *testing.T) {
require.NotEmpty(t, roleID, "role ID should be resolved before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
rolePayload := gjson.Get(result.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", result.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", result.Stdout)
assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String())
})
t.Run("update", func(t *testing.T) {
require.NotEmpty(t, roleID, "role ID should be resolved before update")
updatedRoleName := roleName + " Updated"
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-update", "--base-token", baseToken, "--role-id", roleID, "--json", `{"role_name":"` + updatedRoleName + `","role_type":"custom_role"}`, "--yes"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
})
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const cleanupTimeout = 30 * time.Second
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
parentT.Helper()
if err != nil {
parentT.Errorf("%s: %v", prefix, err)
return
}
if result == nil {
parentT.Errorf("%s: nil result", prefix)
return
}
if isCleanupSuppressedResult(result) {
return
}
parentT.Errorf("%s failed: exit=%d stdout=%s stderr=%s", prefix, result.ExitCode, result.Stdout, result.Stderr)
}
func cleanupContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), cleanupTimeout)
}
func isCleanupSuppressedResult(result *clie2e.Result) bool {
if result == nil {
return false
}
raw := strings.TrimSpace(result.Stdout)
if raw == "" {
raw = strings.TrimSpace(result.Stderr)
}
if raw == "" {
return false
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return false
}
payload := raw[start:]
if !gjson.Valid(payload) {
return false
}
if gjson.Get(payload, "error.type").String() != "api_error" {
return false
}
if gjson.Get(payload, "error.detail.type").String() == "not_found" ||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), "not found") {
return true
}
return gjson.Get(payload, "error.code").Int() == 800004135 ||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), " limited")
}
func createBaseWithRetry(t *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
baseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
if baseToken == "" {
baseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
}
require.NotEmpty(t, baseToken, "stdout:\n%s", result.Stdout)
return baseToken
}
func createTableWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) {
t.Helper()
args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name}
if fieldsJSON != "" {
args = append(args, "--fields", fieldsJSON)
}
if viewJSON != "" {
args = append(args, "--view", viewJSON)
}
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
tableID = gjson.Get(result.Stdout, "data.table.id").String()
if tableID == "" {
tableID = gjson.Get(result.Stdout, "data.table.table_id").String()
}
require.NotEmpty(t, tableID, "stdout:\n%s", result.Stdout)
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.id").String()
if primaryFieldID == "" {
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.field_id").String()
}
primaryViewID = gjson.Get(result.Stdout, "data.views.0.id").String()
if primaryViewID == "" {
primaryViewID = gjson.Get(result.Stdout, "data.views.0.view_id").String()
}
parentT.Cleanup(func() {
cleanupCtx, cancel := cleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"},
DefaultAs: "bot",
})
if deleteErr != nil || deleteResult.ExitCode != 0 {
reportCleanupFailure(parentT, "delete table "+tableID, deleteResult, deleteErr)
}
})
return tableID, primaryFieldID, primaryViewID
}
func createRole(t *testing.T, ctx context.Context, baseToken string, body string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+role-create", "--base-token", baseToken, "--json", body},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
return gjson.Get(result.Stdout, "data.role_id").String()
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestCalendar_CreateEvent tests the workflow of creating a calendar event.
func TestCalendar_CreateEvent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
eventSummary := "lark-cli-e2e-event-" + suffix
eventDescription := "test event description"
startAt := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Minute)
endAt := startAt.Add(1 * time.Hour)
startTime := startAt.Format(time.RFC3339)
endTime := endAt.Format(time.RFC3339)
var eventID string
calendarID := getPrimaryCalendarID(t, ctx)
t.Run("create event with shortcut", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+create",
"--summary", eventSummary,
"--start", startTime,
"--end", endTime,
"--calendar-id", calendarID,
"--description", eventDescription,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
eventID = gjson.Get(result.Stdout, "data.event_id").String()
require.NotEmpty(t, eventID)
})
t.Run("verify event created", func(t *testing.T) {
require.NotEmpty(t, eventID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, eventSummary, gjson.Get(result.Stdout, "data.event.summary").String())
assert.Equal(t, eventDescription, gjson.Get(result.Stdout, "data.event.description").String())
assert.Equal(t, unixSecondsRFC3339(startAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String())
assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
})
t.Run("delete event", func(t *testing.T) {
require.NotEmpty(t, eventID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestCalendar_ManageCalendar tests the workflow of managing calendars.
func TestCalendar_ManageCalendar(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
calendarSummary := "lark-cli-e2e-cal-" + suffix
updatedCalendarSummary := calendarSummary + "-updated"
calendarDescription := "test calendar created by e2e"
var createdCalendarID string
t.Run("list calendars", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.NotEmpty(t, gjson.Get(result.Stdout, "data.calendar_list").Array(), "stdout:\n%s", result.Stdout)
})
t.Run("get primary calendar", func(t *testing.T) {
primaryCalendarID := getPrimaryCalendarID(t, ctx)
require.NotEmpty(t, primaryCalendarID)
})
t.Run("create calendar", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": calendarSummary,
"description": calendarDescription,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String()
require.NotEmpty(t, createdCalendarID)
})
t.Run("get created calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, createdCalendarID, gjson.Get(result.Stdout, "data.calendar_id").String())
assert.Equal(t, calendarSummary, gjson.Get(result.Stdout, "data.summary").String())
assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String())
})
t.Run("find created calendar in list", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.True(t, gjson.Get(result.Stdout, `data.calendar_list.#(calendar_id=="`+createdCalendarID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("update calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "patch"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
Data: map[string]any{
"summary": updatedCalendarSummary,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
t.Run("verify updated calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String())
})
t.Run("delete calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"strconv"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func getPrimaryCalendarID(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "primary"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
return calendarID
}
func unixSecondsRFC3339(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
)
func TestContact_GetUser_BotWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
var targetOpenID string
t.Run("discover-user-via-api", func(t *testing.T) {
// Bot identity cannot use +search-user or +get-user (self).
// However, it CAN call the raw API to list users if it has contact permissions.
// We use this to discover a real open_id for the next step.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/contact/v3/users"},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
targetOpenID = gjson.Get(result.Stdout, "data.items.0.open_id").String()
require.NotEmpty(t, targetOpenID, "expected to find at least one user via raw API")
})
t.Run("get-user-by-id-as-bot", func(t *testing.T) {
require.NotEmpty(t, targetOpenID, "targetOpenID should be populated")
// DefaultAs is automatically "bot" in the clie2e framework
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user", "--user-id", targetOpenID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
returnedID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.Equal(t, targetOpenID, returnedID)
})
}

View File

@@ -16,6 +16,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
@@ -57,6 +58,15 @@ type Result struct {
RunErr error
}
// RetryOptions configures retry behavior for flaky external API calls.
type RetryOptions struct {
Attempts int
InitialDelay time.Duration
MaxDelay time.Duration
BackoffMultiple int
ShouldRetry func(*Result) bool
}
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
func RunCmd(ctx context.Context, req Request) (*Result, error) {
binaryPath, err := ResolveBinaryPath(req)
@@ -99,6 +109,63 @@ func RunCmd(ctx context.Context, req Request) (*Result, error) {
return result, nil
}
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {
opts.Attempts = 4
}
if opts.InitialDelay <= 0 {
opts.InitialDelay = 1 * time.Second
}
if opts.MaxDelay <= 0 {
opts.MaxDelay = 6 * time.Second
}
if opts.BackoffMultiple <= 1 {
opts.BackoffMultiple = 2
}
if opts.ShouldRetry == nil {
opts.ShouldRetry = func(result *Result) bool {
return result != nil && result.ExitCode != 0
}
}
delay := opts.InitialDelay
var lastResult *Result
for attempt := 1; attempt <= opts.Attempts; attempt++ {
result, err := RunCmd(ctx, req)
if err != nil {
return nil, err
}
lastResult = result
if attempt == opts.Attempts || !opts.ShouldRetry(result) {
return result, nil
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return lastResult, nil
case <-timer.C:
}
nextDelay := delay * time.Duration(opts.BackoffMultiple)
if nextDelay > opts.MaxDelay {
delay = opts.MaxDelay
} else {
delay = nextDelay
}
}
return lastResult, nil
}
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
func GenerateSuffix() string {
now := time.Now().UTC()
return fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond())
}
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
func ResolveBinaryPath(req Request) (string, error) {
if req.BinaryPath != "" {

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
func TestDocs_CreateAndFetchWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-docs-folder-" + suffix
docTitle := "lark-cli-e2e-docs-" + suffix
docContent := "# Test Document\n\nThis document was created by lark-cli e2e test."
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, ctx, folderToken, docTitle, docContent)
})
t.Run("fetch", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before fetch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", docToken,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String())
})
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle.
func TestDocs_UpdateWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-update-folder-" + suffix
originalTitle := "lark-cli-e2e-update-" + suffix
updatedTitle := "lark-cli-e2e-update-updated-" + suffix
originalContent := "# Original\n\nThis is the original content."
updatedContent := "# Updated\n\nThis is the updated content."
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, ctx, folderToken, originalTitle, originalContent)
})
t.Run("update-title-and-content", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before update")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+update",
"--doc", docToken,
"--mode", "overwrite",
"--markdown", updatedContent,
"--new-title", updatedTitle,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("verify", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before verify")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", docToken,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String())
})
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func createDocsFolderWithRetry(t *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"drive", "files", "create_folder"},
Data: map[string]any{
"name": name,
"folder_token": "",
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
folderToken := gjson.Get(result.Stdout, "data.token").String()
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
return folderToken
}
func createDocWithRetry(t *testing.T, ctx context.Context, folderToken string, title string, markdown string) string {
t.Helper()
require.NotEmpty(t, folderToken, "folder token is required")
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--folder-token", folderToken,
"--title", title,
"--markdown", markdown,
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
docToken := gjson.Get(result.Stdout, "data.doc_id").String()
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
return docToken
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
)
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-folder-" + suffix
t.Run("create_folder", func(t *testing.T) {
folderToken := createDriveFolder(t, parentT, ctx, folderName)
if folderToken == "" {
t.Fatalf("folder token should be available")
}
})
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// createDriveFolder creates a private folder for the current workflow and
// deletes it during cleanup.
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "files", "create_folder"},
DefaultAs: "bot",
Data: map[string]any{
"name": name,
"folder_token": "",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
folderToken := gjson.Get(result.Stdout, "data.token").String()
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
clie2e.RunCmd(context.Background(), clie2e.Request{
Args: []string{"drive", "files", "delete"},
DefaultAs: "bot",
Params: map[string]any{"file_token": folderToken, "type": "folder"},
})
})
return folderToken
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_ChatUpdateWorkflow tests the +chat-update shortcut.
func TestIM_ChatUpdateWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
originalName := "lark-cli-e2e-im-update-" + suffix
updatedName := originalName + "-updated"
updatedDescription := "Updated description for e2e test"
chatID := createChat(t, parentT, ctx, originalName)
t.Run("update chat name", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--name", updatedName,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("update chat description", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--description", updatedDescription,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("get updated chat", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, updatedName, gjson.Get(result.Stdout, "data.name").String())
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.description").String())
})
}
// TestIM_ChatsGetWorkflow tests the im chats get command.
func TestIM_ChatsGetWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-chats-get-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat info", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
t.Logf("chats get result: %s", result.Stdout)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
dataExists := gjson.Get(result.Stdout, "data").Exists()
require.True(t, dataExists, "data object should exist")
chatNameGot := gjson.Get(result.Stdout, "data.name").String()
require.Equal(t, chatName, chatNameGot)
})
}
// TestIM_ChatsLinkWorkflow tests the im chats link command.
func TestIM_ChatsLinkWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-chats-link-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat share link", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "link"},
Params: map[string]any{"chat_id": chatID},
Data: map[string]any{
"validity_period": "week",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
shareLink := gjson.Get(result.Stdout, "data.share_link").String()
require.NotEmpty(t, shareLink, "share_link should not be empty")
t.Logf("Generated share link: %s", shareLink)
})
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// createChat creates a private chat with the given name and returns the chatID.
// The chat will be automatically cleaned up via parentT.Cleanup().
// Note: Chat deletion is not available via lark-cli im command.
func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-create",
"--name", name,
"--type", "private",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
require.NotEmpty(t, chatID, "chat_id should not be empty")
parentT.Cleanup(func() {
// No IM chat delete command is currently available in lark-cli,
// so created chats are intentionally left in the test account.
})
return chatID
}
// sendMessage sends a text message to the specified chat and returns the messageID.
func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, text string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--chat-id", chatID,
"--text", text,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
messageID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, messageID, "message_id should not be empty")
return messageID
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// TestIM_MessagesReplyWorkflow tests the +messages-reply shortcut.
func TestIM_MessagesReplyWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-im-reply-" + suffix
originalMessage := "Original message for reply test"
replyText := "This is a reply"
chatID := createChat(t, parentT, ctx, chatName)
messageID := sendMessage(t, parentT, ctx, chatID, originalMessage)
t.Run("reply to message with text", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("reply to message with markdown", func(t *testing.T) {
markdownReply := "**Bold** reply"
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--markdown", markdownReply,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}

Some files were not shown because too many files have changed in this diff Show More