Skip to content
Open
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,
}
}

// 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
}
185 changes: 185 additions & 0 deletions shortcuts/lingo/lingo_entity_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package lingo

import (
"context"
"encoding/json"
"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.
//
// Accepts either a plain-text description (--description) or an HTML
// rich-text body (--rich-text); the two flags are mutually exclusive.
// Optional structured metadata (classifications, related users/chats/docs/
// links/images/oncalls, abbreviation cross-links) can be attached via
// --related-meta as a JSON object.
var LingoEntityCreate = common.Shortcut{
Service: "lingo",
Command: "+create",
Description: "Create a dictionary entry (enters review queue by default; supports --description/--rich-text and --related-meta)",
Risk: "write",
Scopes: []string{"baike:entity"},
AuthTypes: []string{"user", "bot"},
Comment on lines +32 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict +create to bot auth only.

Line 33 currently allows user, but this write endpoint is bot-only in the behavior documented for this feature and should be blocked at CLI preflight.

Suggested fix
-	AuthTypes:   []string{"user", "bot"},
+	AuthTypes:   []string{"bot"},
📝 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
Scopes: []string{"baike:entity"},
AuthTypes: []string{"user", "bot"},
Scopes: []string{"baike:entity"},
AuthTypes: []string{"bot"},
🤖 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/lingo_entity_create.go` around lines 32 - 33, The endpoint
registration currently allows both "user" and "bot" auth but must be bot-only;
update the AuthTypes slice in the shortcut registration (where Scopes:
[]string{"baike:entity"} and AuthTypes is defined) to only include "bot" (i.e.,
change AuthTypes: []string{"user", "bot"} to AuthTypes: []string{"bot"}) so CLI
preflight and routing will block user-authenticated requests to the +create
write endpoint.

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: "plain-text description (mutually exclusive with --rich-text)", Input: []string{common.File, common.Stdin}},
{Name: "rich-text", Desc: "HTML rich-text description, e.g. <p><b>主词</b><span>释义</span></p> (mutually exclusive with --description)", Input: []string{common.File, common.Stdin}},
{Name: "related-meta", Desc: "related metadata as JSON object: classifications/abbreviations/users/chats/docs/links/oncalls/images (supports @file or stdin)", 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

Check warning on line 51 in shortcuts/lingo/lingo_entity_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/lingo/lingo_entity_create.go#L51

Added line #L51 was not covered by tests
}
if v := runtime.Str("aliases"); v != "" {
if err := validate.RejectControlChars(v, "aliases"); err != nil {
return err

Check warning on line 55 in shortcuts/lingo/lingo_entity_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/lingo/lingo_entity_create.go#L55

Added line #L55 was not covered by tests
}
}
desc := runtime.Str("description")
rich := runtime.Str("rich-text")
if desc != "" && rich != "" {
return common.FlagErrorf("--description and --rich-text are mutually exclusive")
}
if desc != "" {
if err := validate.RejectControlChars(desc, "description"); err != nil {
return err

Check warning on line 65 in shortcuts/lingo/lingo_entity_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/lingo/lingo_entity_create.go#L65

Added line #L65 was not covered by tests
}
}
if rich != "" {
if err := validate.RejectControlChars(rich, "rich-text"); err != nil {
return err
}
}
if v := runtime.Str("related-meta"); v != "" {
if _, err := parseRelatedMeta(v); err != nil {
return err
}
}
if v := runtime.Str("repo-id"); v != "" {
if err := validate.RejectControlChars(v, "repo-id"); err != nil {
return err

Check warning on line 80 in shortcuts/lingo/lingo_entity_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/lingo/lingo_entity_create.go#L79-L80

Added lines #L79 - L80 were not covered by tests
}
}
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

Check warning on line 103 in shortcuts/lingo/lingo_entity_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/lingo/lingo_entity_create.go#L100-L103

Added lines #L100 - L103 were not covered by tests
}
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)")

Check warning on line 107 in shortcuts/lingo/lingo_entity_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/lingo/lingo_entity_create.go#L105-L107

Added lines #L105 - L107 were not covered by tests
})
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
} else if rich := runtime.Str("rich-text"); rich != "" {
body["rich_text"] = rich
}

if rm := runtime.Str("related-meta"); rm != "" {
// Validate already ran; ignore error here.
if parsed, err := parseRelatedMeta(rm); err == nil {
body["related_meta"] = parsed
}
}

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

Check warning on line 150 in shortcuts/lingo/lingo_entity_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/lingo/lingo_entity_create.go#L150

Added line #L150 was not covered by tests
}

return body
}

// parseRelatedMeta unmarshals the --related-meta flag value into a JSON object.
// Returns an actionable error if the input is not a valid JSON object.
func parseRelatedMeta(raw string) (map[string]interface{}, error) {
var out map[string]interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, common.FlagErrorf("--related-meta must be a JSON object: %s", err)
}
if out == nil {
return nil, common.FlagErrorf("--related-meta must be a JSON object, got null")
}
return out, nil
}

// 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

Check warning on line 177 in shortcuts/lingo/lingo_entity_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/lingo/lingo_entity_create.go#L177

Added line #L177 was not covered by tests
}
out = append(out, map[string]interface{}{
"key": k,
"display_status": display,
})
}
return out
}
Loading
Loading