Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 🕐 Attendance | Query personal attendance check-in records |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📚 Lingo | Search, match, get, create, update, delete enterprise dictionary entries (Feishu Baike) |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |

## Installation & Quick Start
Expand Down Expand Up @@ -159,6 +160,7 @@ lark-cli auth status
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
| `lark-lingo` | Enterprise dictionary entry search, match, get, create, update, delete |

## Authentication

Expand Down
2 changes: 2 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
| 📚 词典 | 搜索、精准匹配、获取详情、创建、修改、删除企业词典词条(飞书百科) |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |

## 安装与快速开始
Expand Down Expand Up @@ -160,6 +161,7 @@ lark-cli auth status
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
| `lark-okr` | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
| `lark-lingo` | 企业词典词条搜索、精准匹配、获取详情、创建、修改、删除 |

## 认证

Expand Down
4 changes: 4 additions & 0 deletions internal/registry/service_descriptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,9 @@
"okr": {
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators, progresses" },
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标、进展记录" }
},
"lingo": {
"en": { "title": "Lingo", "description": "Dictionary entry search, match, get, create, update, delete" },
"zh": { "title": "词典", "description": "词条搜索、精准匹配、获取详情、创建、修改、删除" }
}
}
58 changes: 58 additions & 0 deletions shortcuts/lingo/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package lingo

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/shortcuts/common"
)

// lingoTestConfig builds an isolated CliConfig per test so cached state
// (tokens, profile) cannot leak across parallel tests.
func lingoTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
suffix := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-lingo-" + suffix,
AppSecret: "secret-lingo-" + suffix,
Brand: core.BrandFeishu,
}
}
Comment on lines +21 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Set LARKSUITE_CLI_CONFIG_DIR in the shared test config helper.

Adding it here keeps all lingo shortcut tests consistently isolated.

Suggested fix
 func lingoTestConfig(t *testing.T) *core.CliConfig {
 	t.Helper()
+	t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
 	suffix := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name()))
 	return &core.CliConfig{

As per coding guidelines: Use t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) to isolate config state in tests.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func lingoTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
suffix := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-lingo-" + suffix,
AppSecret: "secret-lingo-" + suffix,
Brand: core.BrandFeishu,
}
}
func lingoTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
suffix := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-lingo-" + suffix,
AppSecret: "secret-lingo-" + suffix,
Brand: core.BrandFeishu,
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/lingo/helpers_test.go` around lines 21 - 29, The shared test helper
lingoTestConfig does not isolate config state; update the lingoTestConfig(t
*testing.T) helper to call t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
(before returning the *core.CliConfig) so all lingo shortcut tests use a unique
temporary config directory; keep the rest of the function (suffix computation
and returned core.CliConfig) unchanged.


// runLingoShortcut mounts the given shortcut under a fresh "lingo" parent
// and executes it with the given args.
func runLingoShortcut(t *testing.T, s common.Shortcut, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "lingo"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}

// decodeEnvelope parses the success envelope and returns the data field.
func decodeEnvelope(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
}
139 changes: 139 additions & 0 deletions shortcuts/lingo/lingo_entity_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package lingo

import (
"context"
"fmt"
"io"
"strings"

"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)

// LingoEntityCreate creates a new dictionary entry.
// Entries created via this API default to the review queue unless the
// app has been granted baike:entity:exempt_review.
var LingoEntityCreate = common.Shortcut{
Service: "lingo",
Command: "+create",
Description: "Create a dictionary entry (enters review queue by default)",
Risk: "write",
Scopes: []string{"baike:entity"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "main-key", Desc: "main key (required, e.g. \"飞书\")", Required: true},
{Name: "aliases", Desc: "comma-separated alias list (optional)"},
{Name: "description", Desc: "entry description text", Input: []string{common.File, common.Stdin}},
{Name: "repo-id", Desc: "dictionary repo ID; empty = shared company dictionary"},
{Name: "allow-highlight", Type: "bool", Default: "true", Desc: "whether the entry is highlighted in documents"},
{Name: "allow-search", Type: "bool", Default: "true", Desc: "whether the entry participates in search"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
mainKey := strings.TrimSpace(runtime.Str("main-key"))
if mainKey == "" {
return common.FlagErrorf("--main-key cannot be empty")
}
if err := validate.RejectControlChars(mainKey, "main-key"); err != nil {
return err
}
if v := runtime.Str("aliases"); v != "" {
if err := validate.RejectControlChars(v, "aliases"); err != nil {
return err
}
}
if v := runtime.Str("description"); v != "" {
if err := validate.RejectControlChars(v, "description"); err != nil {
return err
}
}
if v := runtime.Str("repo-id"); v != "" {
if err := validate.RejectControlChars(v, "repo-id"); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateBody(runtime)
return common.NewDryRunAPI().
POST("/open-apis/lingo/v1/entities").
Body(body).
Desc("Create dictionary entry")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildCreateBody(runtime)
data, err := runtime.DoAPIJSON("POST", "/open-apis/lingo/v1/entities", larkcore.QueryParams{}, body)
if err != nil {
return err
}

runtime.OutFormat(data, nil, func(w io.Writer) {
entity, _ := data["entity"].(map[string]interface{})
if entity == nil {
fmt.Fprintln(w, "Created (no entity echoed)")
return
}
id, _ := entity["id"].(string)
fmt.Fprintf(w, "Created entity [%s] %s\n", id, mainKeyText(entity))
fmt.Fprintln(w, " (entries enter review queue unless app has baike:entity:exempt_review)")
})
return nil
},
}

// buildCreateBody assembles the create request body from flags.
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
display := map[string]interface{}{
"allow_highlight": runtime.Bool("allow-highlight"),
"allow_search": runtime.Bool("allow-search"),
}

mainKey := map[string]interface{}{
"key": runtime.Str("main-key"),
"display_status": display,
}

body := map[string]interface{}{
"main_keys": []map[string]interface{}{mainKey},
}

if aliasStr := runtime.Str("aliases"); aliasStr != "" {
aliases := splitAliases(aliasStr, display)
if len(aliases) > 0 {
body["aliases"] = aliases
}
}

if desc := runtime.Str("description"); desc != "" {
body["description"] = desc
}

if repo := runtime.Str("repo-id"); repo != "" {
body["repo_id"] = repo
}

return body
}

// splitAliases splits a comma-separated alias list and pairs each with the display_status block.
// Empty values (after trim) are skipped.
func splitAliases(s string, display map[string]interface{}) []map[string]interface{} {
parts := strings.Split(s, ",")
out := make([]map[string]interface{}, 0, len(parts))
for _, p := range parts {
k := strings.TrimSpace(p)
if k == "" {
continue
}
out = append(out, map[string]interface{}{
"key": k,
"display_status": display,
})
}
return out
}
142 changes: 142 additions & 0 deletions shortcuts/lingo/lingo_entity_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package lingo

import (
"strings"
"testing"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)

func TestEntityCreateValidate_MissingMainKey(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t))
err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{"+create"})
if err == nil {
t.Fatal("expected error for missing --main-key")
}
if !strings.Contains(err.Error(), "main-key") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestEntityCreateValidate_BlankMainKey(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t))
err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{
"+create",
"--main-key", " ",
})
if err == nil {
t.Fatal("expected error for whitespace-only --main-key")
}
if !strings.Contains(err.Error(), "main-key") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestEntityCreateDryRun_MinimalBody(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t))
err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{
"+create",
"--main-key", "KYC",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/lingo/v1/entities") {
t.Fatalf("dry-run output missing API path, got: %s", out)
}
if !strings.Contains(out, "KYC") {
t.Fatalf("dry-run output missing main-key, got: %s", out)
}
if !strings.Contains(out, "main_keys") {
t.Fatalf("dry-run body should contain main_keys, got: %s", out)
}
// No aliases / description provided → those keys must be absent.
if strings.Contains(out, "\"aliases\"") {
t.Fatalf("dry-run body should NOT contain aliases when flag absent, got: %s", out)
}
if strings.Contains(out, "\"description\"") {
t.Fatalf("dry-run body should NOT contain description when flag absent, got: %s", out)
}
}

func TestEntityCreateDryRun_AliasesAndDescription(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t))
err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{
"+create",
"--main-key", "飞书",
"--aliases", "Lark, FeiShu , 飞书办公",
"--description", "企业协作平台",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Lark", "FeiShu", "飞书办公", "企业协作平台"} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run missing %q, got: %s", want, out)
}
}
}

func TestEntityCreateExecute_OK(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/lingo/v1/entities",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entity": map[string]interface{}{
"id": "ent-new",
"main_keys": []interface{}{map[string]interface{}{"key": "KYC"}},
},
},
},
})
err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{
"+create",
"--main-key", "KYC",
"--description", "Know Your Customer",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
entity, _ := data["entity"].(map[string]interface{})
if entity == nil || entity["id"] != "ent-new" {
t.Fatalf("missing or wrong entity in data: %#v", data)
}
}

func TestEntityCreateExecute_PermissionError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/lingo/v1/entities",
Body: map[string]interface{}{
"code": 99991672,
"msg": "Permission denied",
},
})
err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{
"+create",
"--main-key", "KYC",
})
if err == nil {
t.Fatal("expected error for API permission failure")
}
}
Loading
Loading