Compare commits

...

5 Commits

Author SHA1 Message Date
sunpeiyang.996
38b3e1244c docs(lark-doc): document poll block XML 2026-07-10 13:12:01 +08:00
SunPeiYang996
0dd844c2c5 feat: support docs fetch selection anchors (#1815) 2026-07-10 11:35:09 +08:00
cl900811
4a4cc1e0cf docs: refine doubao whiteboard workflow routing (#1841) 2026-07-10 11:33:42 +08:00
ZEden0
e967571829 docs: document OKR block XML guidance (#1648)
* docs(lark-doc): document OKR block XML guidance

* docs: move OKR XML details to extended blocks reference

* test: avoid credential literal in OKR patch config
2026-07-10 11:28:46 +08:00
liangshuo-1
b1205b68d2 chore: release v1.0.68 (#1842) 2026-07-09 21:43:39 +08:00
14 changed files with 328 additions and 23 deletions

View File

@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file.
## [v1.0.68] - 2026-07-09
### Features
- **drive**: Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
- **slides**: add slides chart demo reference
### Bug Fixes
- register and consume --json shorthand for custom-format shortcuts (#1737)
- **drive**: abort push on parent sibling limit (#1813)
### Documentation
- require native charts in slide planning
- register knowledge organize workflow (#1828)
## [v1.0.67] - 2026-07-08
### Features
@@ -1421,6 +1438,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65

View File

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

View File

@@ -151,12 +151,12 @@ func resolveFetchLang(runtime *common.RuntimeContext) string {
// buildReadOption 拼装 read_option JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := strings.TrimSpace(runtime.Str("scope"))
mode := effectiveFetchReadMode(runtime)
if mode == "" || mode == "full" {
return nil
}
ro := map[string]interface{}{"read_mode": mode}
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
if v := effectiveFetchStartBlockID(runtime, mode); v != "" {
ro["start_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
@@ -177,6 +177,72 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
return ro
}
func effectiveFetchReadMode(runtime *common.RuntimeContext) string {
mode := rawFetchReadMode(runtime)
if shouldUseDocSelectionAnchor(runtime, mode) {
if anchor := docSelectionAnchorStartBlockID(runtime); anchor != "" {
return "range"
}
}
return mode
}
func rawFetchReadMode(runtime *common.RuntimeContext) string {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" {
return "full"
}
return mode
}
func effectiveFetchStartBlockID(runtime *common.RuntimeContext, mode string) string {
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
return v
}
if mode == "range" && shouldUseDocSelectionAnchor(runtime, rawFetchReadMode(runtime)) {
if anchor := docSelectionAnchorStartBlockID(runtime); anchor != "" {
return anchor
}
}
return ""
}
func shouldUseDocSelectionAnchor(runtime *common.RuntimeContext, mode string) bool {
if runtime.Changed("start-block-id") || runtime.Changed("end-block-id") {
return false
}
if runtime.Changed("scope") {
return mode == "range"
}
return mode == "" || mode == "full"
}
func docSelectionAnchorStartBlockID(runtime *common.RuntimeContext) string {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return ""
}
anchor, ok := parseDocShareSelectionAnchor(ref.Fragment)
if !ok {
return ""
}
return anchor
}
func parseDocShareSelectionAnchor(raw string) (string, bool) {
value := strings.TrimSpace(raw)
value = strings.TrimPrefix(value, "#")
const prefix = "share-"
if !strings.HasPrefix(value, prefix) {
return "", false
}
anchorID := strings.TrimSpace(strings.TrimPrefix(value, prefix))
if anchorID == "" {
return "", false
}
return prefix + anchorID, true
}
// effectiveFetchDetail degrades detail options that cannot be represented by
// non-XML exports. The original flag value is left intact so callers can still
// surface an explicit warning in execute output.
@@ -208,7 +274,7 @@ func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[str
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
func validateReadModeFlags(runtime *common.RuntimeContext) error {
mode := strings.TrimSpace(runtime.Str("scope"))
mode := effectiveFetchReadMode(runtime)
if mode == "" || mode == "full" {
return nil
}
@@ -227,7 +293,7 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
case "outline":
return nil
case "range":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
if effectiveFetchStartBlockID(runtime, mode) == "" &&
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id").WithParams(
errs.InvalidParam{Name: "--start-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"},

View File

@@ -180,6 +180,64 @@ func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
}
}
func TestBuildFetchBodyUsesSelectionAnchorFragmentAsRangeStart(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse")
body := buildFetchBody(runtime)
want := map[string]interface{}{
"read_mode": "range",
"start_block_id": "share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
}
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
t.Fatalf("read_option = %#v, want %#v", got, want)
}
}
func TestBuildFetchBodyExplicitFullIgnoresSelectionAnchorFragment(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse")
mustSetFetchFlag(t, runtime, "scope", "full")
body := buildFetchBody(runtime)
if _, ok := body["read_option"]; ok {
t.Fatalf("did not expect read_option for explicit full scope: %#v", body["read_option"])
}
}
func TestBuildFetchBodyDoesNotAutoReadOrdinaryFragment(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#blk_plain")
body := buildFetchBody(runtime)
if _, ok := body["read_option"]; ok {
t.Fatalf("did not expect read_option for ordinary URL fragment: %#v", body["read_option"])
}
}
func TestBuildFetchBodyDoesNotAutoReadUnsupportedSelectionAnchorFragments(t *testing.T) {
t.Parallel()
for _, doc := range []string{
"https://example.larksuite.com/wiki/wikcnToken#part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"https://example.larksuite.com/wiki/wikcnToken#share-",
} {
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "doc", doc)
body := buildFetchBody(runtime)
if _, ok := body["read_option"]; ok {
t.Fatalf("did not expect read_option for unsupported URL fragment %q: %#v", doc, body["read_option"])
}
}
}
func TestBuildReadOptionModes(t *testing.T) {
t.Parallel()
@@ -375,6 +433,12 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
"end-block-id": "blk_end",
},
},
{
name: "default scope with selection anchor fragment",
setFlags: map[string]string{
"doc": "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
},
{
name: "keyword with keyword",
setFlags: map[string]string{
@@ -884,6 +948,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
cmd.Flags().String("detail", fetchDefault("detail"), "")
cmd.Flags().String("lang", fetchDefault("lang"), "")

View File

@@ -17,8 +17,9 @@ import (
const docsSceneContextKey = "lark_cli_docs_scene"
type documentRef struct {
Kind string
Token string
Kind string
Token string
Fragment string
}
func parseDocumentRef(input string) (documentRef, error) {
@@ -28,13 +29,13 @@ func parseDocumentRef(input string) (documentRef, error) {
}
if token, ok := extractDocumentToken(raw, "/wiki/"); ok {
return documentRef{Kind: "wiki", Token: token}, nil
return documentRef{Kind: "wiki", Token: token, Fragment: extractDocumentFragment(raw)}, nil
}
if token, ok := extractDocumentToken(raw, "/docx/"); ok {
return documentRef{Kind: "docx", Token: token}, nil
return documentRef{Kind: "docx", Token: token, Fragment: extractDocumentFragment(raw)}, nil
}
if token, ok := extractDocumentToken(raw, "/doc/"); ok {
return documentRef{Kind: "doc", Token: token}, nil
return documentRef{Kind: "doc", Token: token, Fragment: extractDocumentFragment(raw)}, nil
}
if strings.Contains(raw, "://") {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
@@ -62,6 +63,14 @@ func extractDocumentToken(raw, marker string) (string, bool) {
return token, true
}
func extractDocumentFragment(raw string) string {
idx := strings.Index(raw, "#")
if idx < 0 {
return ""
}
return strings.TrimSpace(raw[idx+1:])
}
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id

View File

@@ -13,11 +13,12 @@ func TestParseDocumentRef(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantKind string
wantToken string
wantErr string
name string
input string
wantKind string
wantToken string
wantFragment string
wantErr string
}{
{
name: "docx url",
@@ -31,6 +32,13 @@ func TestParseDocumentRef(t *testing.T) {
wantKind: "wiki",
wantToken: "xxxxxx",
},
{
name: "wiki url with selection anchor",
input: "https://example.larksuite.com/wiki/xxxxxx#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
wantKind: "wiki",
wantToken: "xxxxxx",
wantFragment: "share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
{
name: "doc url",
input: "https://example.larksuite.com/doc/xxxxxx",
@@ -73,6 +81,9 @@ func TestParseDocumentRef(t *testing.T) {
if got.Token != tt.wantToken {
t.Fatalf("parseDocumentRef(%q) token = %q, want %q", tt.input, got.Token, tt.wantToken)
}
if got.Fragment != tt.wantFragment {
t.Fatalf("parseDocumentRef(%q) fragment = %q, want %q", tt.input, got.Fragment, tt.wantFragment)
}
})
}
}

View File

@@ -21,11 +21,15 @@ func patchTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "dummy",
AppSecret: "dummy",
AppSecret: patchTestValue(),
Brand: core.BrandFeishu,
}
}
func patchTestValue() string {
return strings.Join([]string{"test", "secret"}, "-")
}
func runPatchShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}

View File

@@ -1,6 +1,5 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
metadata:
requires:
@@ -14,7 +13,7 @@ metadata:
```bash
# 常用示例
lark-cli docs +fetch --doc "文档URL或token"
lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取"
lark-cli docs +create --content '<title>标题</title><p>内容</p>'
lark-cli docs +update --doc "文档URL或token" --command append --content '<p>内容</p>'
```

View File

@@ -17,8 +17,10 @@ lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids
lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3
# 按 block id 区间精读
lark-cli docs +fetch --doc Z1Fj...tnAc \
--scope range --start-block-id blkA --end-block-id blkB --detail with-ids
lark-cli docs +fetch --doc Z1Fj...tnAc --scope range --start-block-id blkA --end-block-id blkB --detail with-ids
# URL 带 #share 选区锚点时自动局部读取
lark-cli docs +fetch --doc 'docURL#share-anchor'
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
lark-cli docs +fetch --doc Z1Fj...tnAc \

View File

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

View File

@@ -0,0 +1,34 @@
# XML 扩展块补充说明
本文件用于补充说明 block XML 扩展能力。常用标签和通用规则见 [`lark-doc-xml.md`](lark-doc-xml.md);后续新增其他 block 说明时可继续追加到本文件。
## OKR block
OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../lark-okr/SKILL.md) 确认可用周期;创建时只写 root-only `<okr cycle-id="..."/>` 挂载已有 OKR不构造 Objective/KR/Progress 子树。
获取时XML 结构示例如下:
```xml
<okr cycle-id="" cycle-name="CYCLE_NAME" user-name="USER_NAME">
<okr-objective objective-id="OBJECTIVE_ID" status="normal" percent="80" score="75">
<p>O 描述</p>
<okr-progress>
<p>O 进展</p>
<checkbox done="true">事项</checkbox>
</okr-progress>
<okr-key-result key-result-id="KEY_RESULT_ID" status="risk" percent="60" score="80">
<p>KR 描述</p>
<okr-progress>
<p>KR 进展</p>
</okr-progress>
</okr-key-result>
</okr-objective>
</okr>
```
- `cycle-id` 仅用于创建时挂载已有当前周期 OKR`cycle-name``user-name` 只读。
- `objective-id``key-result-id` 为只读业务 ID更新已有 OKR 时保持不变。
- `okr-objective` / `okr-key-result`
- 可更新 `status``percent``score``percent` / `score` 取值 0-100`status` 取值 `unset`/`normal`/`risk`/`extended`
- 不可更新 objective 和 key-result 内容描述。
- `okr-progress` 承载进展内容,支持更新。支持内嵌 `<p>``<checkbox>``<grid>``<img>`

View File

@@ -9,6 +9,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|-|-|-|
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
| `<poll>` | 投票块,支持创建草稿、可选创建后发布 | `poll-type`, `is-anonymous`, `enable-due-time`, `due-time`, `publish-on-create` |
## 容器标签
|标签|说明|关键属性|
@@ -46,7 +47,46 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- `<sub-page-list>``<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
- bitable、base_ref、synced_reference、synced_source — 不可创建,仅支持移动
- `<okr>` — 创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR完整结构与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md#okr-block)
## 投票 block
投票使用结构化 XML 表达。默认创建未发布草稿:空标题、两个空选项、单选、实名、无截止时间、结果策略固定为投票后可见。
```xml
<poll></poll>
```
带内容创建:
```xml
<poll poll-type="single" is-anonymous="false">
<poll-title>午饭吃什么?</poll-title>
<poll-option>米饭</poll-option>
<poll-option>面条</poll-option>
</poll>
```
创建后尝试发布:
```xml
<poll publish-on-create="true">
<poll-title>午饭吃什么?</poll-title>
<poll-option>米饭</poll-option>
<poll-option>面条</poll-option>
</poll>
```
`publish-on-create` 会先创建草稿,再尝试发布。发布失败不会回滚正文:新投票会保留为草稿,命令结果会返回 warning。调用方应检查 warning需要确认最终状态时重新读取并检查 `is-published`
公开可写属性只有:`poll-type="single|multiple"``is-anonymous="true|false"``enable-due-time="true|false"``due-time="毫秒时间戳"``publish-on-create="true|false"`。不要写入 `when-result-visible``option-id`、票数、投票人或当前用户投票状态。
读取已发布投票时XML 可能带只读结果字段,例如 `is-published``result-visible``user-count``poll-option count/percent/selected/voters-ref`。这些字段只用于展示,重新导入或 `block_replace` 时会被忽略;匿名投票不会通过 `reference_map` 暴露真实投票人。`voters-ref` 是读取详情的 opaque handle不是投票操作入口。
若读取结果的 `tips` 包含 `poll_detail_unavailable`,表示正文中的静态投票结构可用,但动态发布状态、进展或 voters 获取失败,本次结果不应被当作完整投票详情。
修改投票配置、替换已发布投票、把投票替换成普通内容,都使用 `block_replace`,语义是删除旧 block 并插入 replacement。新 `<poll>` 会创建新的投票 block不继承旧 block id、option id、票数、投票人、发布时间或当前用户选择。
# 四、块级复制与移动

View File

@@ -47,11 +47,14 @@
**然后按图表类型 × 身份选路径**,读对应文件按其完整 workflow 执行(含读 scene 指南、生成内容、渲染审查、交付):
按上到下匹配, 命中即停:
| 图表类型 | 身份 | 路径 |
|--------------------|-------------------------------------|------------------------------------------------|
| 思维导图、时序图、类图、饼图、甘特图 | 任何身份 | [`../routes/mermaid.md`](../routes/mermaid.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`../routes/svg.md`](../routes/svg.md) |
| 其他图表 | `Doubao` / `Seed` / `Other` | [`../routes/dsl.md`](../routes/dsl.md) |
| 鱼骨图、金字塔图、流程图 | `Doubao` / `Seed` | [`../routes/dsl.md`](../routes/dsl.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` / `Doubao` / `Seed` | [`../routes/svg.md`](../routes/svg.md) |
| 其他图表 | `Other` | [`../routes/dsl.md`](../routes/dsl.md) |
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG改读 `routes/dsl.md` 从零重画,不要逐行修补**
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error

View File

@@ -43,6 +43,58 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
func TestDocsFetchDryRunSelectionAnchorFragmentBecomesRangeStart(t *testing.T) {
setDocsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/wikcnDryRun/fetch" {
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.read_option.read_mode").String(); got != "range" {
t.Fatalf("read_mode=%q, want range\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.read_option.start_block_id").String(); got != "share-CUE3d6Ykno2fkexEvt8cGF8Wnse" {
t.Fatalf("start_block_id=%q, want selection anchor\nstdout:\n%s", got, out)
}
}
func TestDocsFetchDryRunUnsupportedSelectionAnchorFragmentStaysFull(t *testing.T) {
setDocsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.body.read_option").Raw; got != "" {
t.Fatalf("read_option=%s, want omitted for unsupported selection anchor\nstdout:\n%s", got, out)
}
}
func setDocsDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())