翻完 lark-cli 的 17 万行 Go 代码,我学到了什么

翻完 lark-cli 的 17 万行 Go 代码,我学到了什么

太长不看 :lark-cli 是飞书开源的 CLI 工具,17 万行 Go,200+ 命令,覆盖 18 个业务领域。它有一个特别的设计前提------人类和 AI Agent 都是它的用户 。这个前提一旦往下走,命令怎么分层、错误怎么设计、身份怎么解析、Skill 文件怎么写、安全怎么兜底------每一层都要重新想。这篇文章拆它的完整架构:从命令体系、执行管线、工厂模式,到错误系统、身份系统、Skill 文件、安全护栏、输出体系。翻完最大的感受是: "Agent-Native"不是加个 --json 参数,是把 Agent 当成第二类用户,从第一行代码开始,每一层都为它设计。


最近翻了飞书开源的 lark-cli 的源码。翻它的原因很简单------它的 AGENTS.md 第一段就写了:

This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines --- error messages, output format, and flag design all directly affect agent success rates.

说实话,翻之前我预期不高。大多数号称"AI-Native"的工具,加个 --json 参数就交差了。翻完之后,这篇文章写了快 5000 字------不是故意写长,是这个项目确实值得拆这么细。


一、命令体系:三层,三层信任边界

lark-cli 的命令分三层,不是"高级/中级/低级",是三种信任边界

前缀 给谁用 框架替你做了什么
Shortcuts + 开头 人类 + Agent(稳定接口) 身份解析、Scope 校验、参数验证、错误分类、分页合并、内容安全扫描
API Commands 无前缀 熟悉 API 的开发者 从 OAPI 元数据自动生成,1:1 映射
Raw API api 子命令 逃生舱 什么都不做,你自己负责一切

+ 前缀是刻意设计的。它把 Shortcut 和普通子命令从视觉上隔开------Agent 一眼就能区分"这是框架封装过的稳定命令"和"这是直接映射 API 的命令"。

bash 复制代码
# Shortcut:框架帮你做了身份、Scope、错误分类、分页
lark-cli calendar +agenda
​
# API Command:你自己处理参数和响应
lark-cli calendar events instance_view --params '{"calendar_id":"primary",...}'
​
# Raw API:你自己处理一切
lark-cli api GET /open-apis/calendar/v4/calendars

Agent 应该尽量走 Shortcut,只在 Shortcut 覆盖不到的时候掉到 Raw API。这不是"能力递进",是信任递进------越往下,框架替你做的事情越少,你承担的责任越多。


二、执行管线:一个 Shortcut 从调用到返回,经历了什么

一个 Shortcut 不是"参数解析 → 调用 API → 返回结果"这么简单。它的执行管线有 6 个阶段:

sql 复制代码
lark-cli calendar +agenda --start 2025-03-21
        │
        ▼
  ① Identity 解析
     ├── 读 --as 参数(user / bot / auto)
     ├── 读配置文件 defaultAs
     ├── 读 strict mode(强制 user-only 或 bot-only)
     └── 校验:这个 Shortcut 支持当前身份吗?
        │
        ▼
  ② Config 加载
     ├── 从 Credential 链加载配置(多 profile 支持)
     └── 校验:app_id 和 secret 配了吗?
        │
        ▼
  ③ Scope 预检
     ├── 读 Shortcut 声明的 scopes
     ├── 读当前 token 的 scopes
     └── 缺 scope → 返回 typed error,告诉 Agent 该跑什么命令
        │
        ▼
  ④ RuntimeContext 创建
     ├── 注入 APIClient(懒加载,sync.OnceValues)
     ├── 注入 Lark SDK client
     ├── 解析 --format 和 --jq
     └── 设置 bot-only 标记
        │
        ▼
  ⑤ Validate
     ├── 枚举值校验(--priority 只能是 high/medium/low)
     ├── @file 和 stdin 输入解析
     ├── --jq 表达式合法性检查
     └── 业务逻辑校验(如"bot 不能查用户日程")
        │
        ▼
  ⑥ Execute
     ├── --dry-run?→ 打印请求预览,不执行
     ├── --print-schema?→ 打印 JSON Schema,不执行
     ├── 高风险操作?→ 检查 --yes
     └── 调用 API → 分类错误 → 格式化输出 → 返回

每一步都是独立的 phase,有明确的输入和输出。这套管线的设计哲学是:让框架做所有 Agent 不擅长的事,让 Execute 只做业务逻辑。


三、工厂模式:怎么让 200+ 命令共享依赖,又能独立测试

lark-cli 有 200+ 命令,每个命令都需要访问配置、HTTP 客户端、Lark SDK、凭证链、文件系统、Keychain。如果用全局变量,测试就是灾难。

它的解法是 Factory 模式------一个 struct 持有所有共享依赖,所有函数字段都是懒加载的:

go 复制代码
type Factory struct {
    Config     func() (*core.CliConfig, error) // 懒加载配置
    HttpClient func() (*http.Client, error)    // 懒加载 HTTP 客户端
    LarkClient func() (*lark.Client, error)    // 懒加载 SDK 客户端
    IOStreams  *IOStreams                      // stdin/stdout/stderr
    Keychain   keychain.KeychainAccess         // 系统钥匙串
    Credential *credential.CredentialProvider  // 凭证链
    // ...
}

生产环境用 NewDefault() 创建,测试环境直接替换字段:

go 复制代码
// 测试中 mock 掉所有外部依赖
f := &cmdutil.Factory{
    Config: func() (*core.CliConfig, error) {
        return &core.CliConfig{AppID: "test"}, nil
    },
    IOStreams: cmdutil.NewTestIO(),
    // ...
}

不需要 DI 框架,不需要 wire 或者 dig。Go 的 struct + function field 就够用了。最简单的方案往往是最可测试的方案。


四、错误系统:这是整个项目最值得偷走的东西

大多数 CLI 工具的错误处理思路是:打印一段人类可读的错误消息,exit 非零。Agent 拿到这段消息,只能做字符串匹配。

lark-cli 的错误全部走 stderr 的 JSON 信封:

json 复制代码
{
  "ok": false,
  "identity": "user",
  "error": {
    "type": "authorization",
    "subtype": "missing_scope",
    "code": 99991679,
    "message": "missing scope `calendar:event:create` for app cli_xxx",
    "hint": "run lark-cli auth login --scope calendar:event:create",
    "log_id": "20260520-0a1b2c3d",
    "missing_scopes": ["calendar:event:create"],
    "console_url": "https://open.feishu.cn/app/cli_xxx/auth?q=..."
  }
}

Agent 不需要读 message。它读 typesubtype 两个字段就知道该做什么。

9 种 Category,穷举且封闭:

Category 什么时候用 exit Agent 该做什么
validation 参数填错了 2 params,修参数,重试
authentication 没登录 / 没 token 3 auth login
authorization token 缺 scope 3 auth login --scope
config 本地配置没了 3 config init
network DNS / 超时 / 拒绝连接 4 等一会儿重试
api 飞书 API 返回了错误 1 codelog_id,查文档
policy 内容安全拦截 6 challenge_url,让用户处理
internal 工具自己的 bug 5 停,不要重试,报 bug
confirmation 高风险操作没确认 10 --yes,再跑一次

每个 Category 在 Go 里是一个独立的 struct,带 builder API。Category 锁死在函数名上,Subtype 必须是声明的常量,Message 是给人类看的(Agent 不依赖它):

perl 复制代码
return errs.NewPermissionError(errs.SubtypeMissingScope,
    "missing required scope(s): %s", strings.Join(missing, ", ")).
    WithMissingScopes(missing...).
    WithHint("run: lark-cli auth login --scope %s", strings.Join(missing, " "))

这个契约是用 lint 锁死的,不是靠文档。 项目里跑着两个 golangci-lint 规则加一个自定义 AST 检查模块:

lint 规则 拦住什么
forbidigo 命令边界返回的 fmt.Errorf / errors.New 直接编译失败
CheckDeclaredSubtype Subtype 必须是声明的常量,手写字符串过不了 CI
CheckProblemEmbed 每个 typed error struct 必须嵌入 errs.Problem
错误重新分类禁止 *PermissionError 不能被包成 *InternalError 再往上抛

为什么这很重要?因为一旦 Agent 开始依赖 type: "authorization" → 重新登录,你就不能某天不小心改成了 type: "api"。Agent 会走进错误的分支,重复登录,然后报给用户说"搞不定"。

这不是代码风格问题。是接口契约。给人类改 API 返回格式,他们会骂你。给 Agent 改 error type,它会在用户面前反复做错事。


五、身份系统:user、bot、auto,和 strict mode

lark-cli 支持三种身份,每种对应不同的 token 类型和 API 权限:

身份 含义 token 类型 使用场景
user 以用户身份调用 User Access Token 查自己的日程、发消息
bot 以应用身份调用 Tenant Access Token 群机器人、批量操作
auto 自动检测 根据配置和 credential 自动选 默认行为

身份解析顺序:--as 参数 > 配置文件的 defaultAs > 自动检测。

还有 strict mode ------管理员可以强制 --strict-mode bot,所有命令只能以 bot 身份运行。Agent 在 strict mode 下用 --as user 会直接报错,不会偷偷降级。

每个 Shortcut 声明自己支持的 AuthTypes

csharp 复制代码
var CalendarAgenda = common.Shortcut{
    AuthTypes: []string{"user", "bot"},  // 两种身份都支持
    // ...
}

如果 Shortcut 声明了 AuthTypes: ["bot"],框架会在 Validate 阶段就拒绝 --as user 的调用。Agent 不需要试错------它看到 AuthTypes 元数据就知道该用什么身份。


六、Skill 文件:Agent 的操作手册,嵌入二进制

--help 只能列参数。Agent 需要知道"用户说'帮我约个会'时,应该调哪个命令?先做什么?"。

lark-cli 的做法是给每个业务领域写一份 SKILL.md,通过 //go:embed 嵌入二进制:

lua 复制代码
## 意图路由
| 用户意图 | 路由到 |
|----------|--------|
| "帮我约个会" | +create(先读 schedule-meeting.md) |
| "查今天的日历" | +agenda(注意:用户说的"日历"是"日程") |
| "昨天的会议记录" | 不是 calendar,是 lark-vc |
​
## 前置条件
| 场景 | 前置要求 |
|------|----------|
| 编辑已有日程 | 先定位 event_id(重复日程要定位到实例) |
| 删除/修改后验证 | 等 2 秒再查(API 最终一致性) |

26 个业务领域,每个都有这样一份 Skill 文件。嵌入二进制的好处是版本一致性------升级 CLI,Skill 内容跟着升级,不会出现 Agent 照着旧版 Skill 调新版命令。

Skill 文件不是给人类看的文档,是给 Agent 看的操作手册。它包含意图路由表、前置条件检查、术语映射("用户说的'日历'是'日程'不是'日历容器'")------这些是 Agent 做决策时真正需要的信息。


七、安全护栏:Agent 是不可信的调用者

一个人类打开终端,你默认他可以操作当前目录。一个 Agent 在后台跑命令,你默认它不能

lark-cli 的安全设计围绕一个前提:Agent 填的 flag 值是 untrusted input。

机制 防什么
vfs 抽象层 所有文件 I/O 不走 os.Open,走 internal/vfs。路径校验拒绝绝对路径、../ 穿越、符号链接逃逸、控制字符
输出扫描 output.ScanForSafety 在输出到 stdout 之前扫描内容。Agent A 的输出要进 Agent B 的管道------不让恶意内容通过
dry-run 所有 Shortcut 自动支持 --dry-run。Agent 不确定的时候,先预览请求,不执行
OS Keychain token 不进配置文件,不走环境变量,走系统钥匙串。Agent 读不到,prompt 注入也拿不到
高风险确认 risk: "high-risk-write" 的 Shortcut 需要 --yes,否则返回 type: "confirmation"(exit 10)

这些机制不是做给安全审计看的。Agent 会犯错、会被 prompt 注入、会在循环中重复调用同一个命令。护栏不是防坏人,是防坏结果。


八、输出体系:五种格式,一个信封,一个通知系统

一个命令的输出有三种去向:人类在终端里看、Agent 在解析、下一个命令在管道里。

lark-cli 的 --format 支持五种格式:json / pretty / table / ndjson / csv。加上 --jq 表达式,Agent 可以在命令内部做 JSON 过滤,不需要再 pipe 给 jq

所有输出都走同一个 JSON 信封:

json 复制代码
{
  "ok": true,
  "identity": "user",
  "data": { ... },
  "meta": { "count": 42 },
  "_notice": {
    "update": {
      "current": "1.2.0",
      "latest": "1.3.0",
      "command": "lark-cli update"
    }
  }
}

_notice 字段是推送系统------Agent 可以检查它判断是否需要升级工具。如果 Agent 不检查,它也能正常工作------_notice 不影响 ok: true 的语义。这个设计把"推送通知"变成了"输出信封里的一个字段",不干扰正常流程。


九、常见的坑:大多数 CLI 工具在"Agent 也是用户"上的三个错误

翻完 lark-cli 再回头看其他 CLI 工具,会发现三个特别常见的坑:

反模式 症状 lark-cli 的解法
错误当字符串 fmt.Errorf("permission denied"),Agent 靠正则匹配猜 结构化 JSON,type + subtype 稳定路由
命令扁平化 所有命令都是平级子命令,Agent 分不清哪个是"稳定接口"哪个是"原始 API" 三层体系,+ 前缀隔离
Agent 当可信调用者 没有路径校验、没有输出扫描、没有 dry-run vfs + ScanForSafety + dry-run + keychain

第一个坑是最常见的------几乎所有 CLI 工具都在犯。第二个坑是"有功能但没设计"。第三个坑是最隐蔽的------不出事的时候完全看不出来,一旦 Agent 被 prompt 注入,整个系统就是裸奔的。


十、贯穿始终的设计哲学

翻完 17 万行代码,会发现几条原则贯穿了所有模块:

1. Agent 是第二类用户,不是"人类用户的简化版"。 错误系统需要 typesubtype,因为 Agent 靠它们路由。Skill 文件需要意图路由表,因为 Agent 不知道"用户说的'日历'是'日程'"。安全护栏需要更严格,因为 Agent 会被注入。

2. 契约靠代码锁死,不靠文档。 错误分类用 lint 锁死,Subtype 用常量声明锁死,身份校验在管线里锁死。文档会过时,CI 不会。

3. 框架做 Agent 不擅长的事,Shortcut 只做业务逻辑。 身份解析、Scope 预检、参数验证、错误分类、分页合并、内容安全扫描------这些全部在管线里完成。Execute 只拿到一个干净的 RuntimeContext,直接调 API。

4. 最简单的方案往往是最可测试的方案。 工厂模式不用 DI 框架,vfs 抽象不用重量级 mock 库,测试用 t.Setenvt.TempDir 隔离状态。没有过度设计,每一层抽象都有明确的测试收益。


十一、如果你要做自己的 Agent CLI:从 lark-cli 可以搬走的 8 样东西

翻完 lark-cli 不是为了写一篇读后感。如果你正在做一个会被 Agent 调用的 CLI 工具,下面是 8 个可以直接搬走的设计决策,按优先级排序。

1. 命令分层:+ 前缀隔离稳定接口

问题:Agent 分不清哪个命令是"框架封装过的稳定接口",哪个是"直接映射 API 的原始命令"。

搬走 :给你的 CLI 加一个前缀约定------+ 或者 stable: 都行。前缀命令 = 稳定接口,框架替 Agent 做了校验、错误分类、分页。无前缀命令 = 原始接口,Agent 自己负责一切。

lark-cli 的代码

go 复制代码
// Shortcuts 用 + 前缀,框架自动注入 dry-run、format、jq、身份解析
var CalendarAgenda = common.Shortcut{
    Command: "+agenda",
    Scopes:  []string{"calendar:calendar.event:read"},
    Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
        // 只写业务逻辑,其他全部交给框架
    },
}

2. 错误系统:穷举 Category,稳定性用 lint 锁死

问题:Agent 靠字符串匹配来猜错误类型,换了措辞就死。

搬走 :定义 5-10 种穷举的错误 Category,每种对应一个稳定的 type 字段。每个 Category 配一个 exit code。用 lint 强制所有命令边界返回 typed error,禁止裸 fmt.Errorf

lark-cli 的代码

kotlin 复制代码
// 不要这样写
return fmt.Errorf("permission denied")

// 要这样写------Agent 读 type 和 subtype,不需要读 message
return errs.NewPermissionError(errs.SubtypeMissingScope,
    "missing required scope(s): %s", strings.Join(missing, ", ")).
    WithMissingScopes(missing...).
    WithHint("run: mycli auth login --scope %s", strings.Join(missing, " "))

最小可行版本 :不用一开始就 9 种 Category。3 种就能覆盖 80% 的场景:validation(参数错了)、permission(没权限)、internal(工具自己的 bug)。后面再慢慢加。

3. 执行管线:框架做 Agent 不擅长的事

问题:Agent 每次调命令都要自己处理身份、Scope、参数校验、错误分类、分页------这些不是业务逻辑,但 Agent 经常在这些地方出错。

搬走:设计一个 Shortcut 执行管线,把身份解析、Scope 预检、参数验证、错误分类、分页合并全部放在 Execute 之前。Execute 只拿到一个干净的 RuntimeContext,直接调 API。

sql 复制代码
Identity → Config → Scopes → RuntimeContext → Validate → Execute

lark-cli 的做法 :Shortcut 只声明 ScopesAuthTypesFlagsExecute,剩下的全部由 runShortcut 管线自动完成。你不需要在每个 Shortcut 里重复写身份校验和 Scope 检查。

4. 身份系统:user/bot 双身份 + 强制模式

问题:Agent 有时候需要以用户身份调 API,有时候需要以 bot 身份调。如果全靠 Agent 自己判断,它会搞混。

搬走:定义 2-3 种身份(user、bot、auto),每个命令声明自己支持哪些身份。框架在管线里自动解析和校验。加一个 strict mode 让管理员可以强制只用 bot 身份。

lark-cli 的做法--as user / --as bot 参数 + 配置文件 defaultAs + AuthTypes 声明。如果 Agent 用 --as user 调了一个 bot-only 的命令,框架在 Validate 阶段就拒绝,不会等到 API 调用才发现。

5. 安全护栏:Agent 是 untrusted caller

问题:Agent 会被 prompt 注入,会在循环中重复调用同一个命令,会填恶意 flag 值。

搬走 :三件事------路径校验 (所有文件 I/O 走抽象层,拒绝 ../ 穿越)、dry-run (所有写操作支持预览)、凭证不进配置文件(走系统钥匙串或环境变量,不让 Agent 读到)。

最小可行版本 :dry-run 是性价比最高的。加一个 --dry-run flag,成本几乎为零,但 Agent 在不确定的时候可以先预览。

6. 输出信封:统一格式 + _notice 推送

问题:Agent 需要从输出里同时拿到数据、元数据、系统通知。如果这三样东西散落在 stdout 和 stderr 里,Agent 很难拼起来。

搬走 :所有输出走同一个 JSON 信封,包含 okdatameta_notice 四个字段。_notice 用来推送"有新版本可用"、"Skill 文件过期"这类系统通知,不影响 ok: true 的语义。

json 复制代码
{
  "ok": true,
  "data": { "items": [...] },
  "_notice": {
    "update": { "current": "1.2.0", "latest": "1.3.0", "command": "mycli update" }
  }
}

7. 工厂模式:struct + function field 依赖注入

问题:CLI 工具的命令越来越多,每个命令都需要访问配置、HTTP 客户端、凭证链。用全局变量会让测试变成灾难。

搬走:用一个 Factory struct 持有所有共享依赖,函数字段全部懒加载。测试时直接替换字段,不需要 DI 框架。

go 复制代码
type Factory struct {
    Config  func() (*Config, error)
    Client  func() (*http.Client, error)
    // ...
}

// 测试中
f := &Factory{
    Config: func() (*Config, error) { return &Config{...}, nil },
}

lark-cli 的做法NewDefault() 创建生产环境 Factory,测试用 cmdutil.TestFactory(t, config) 创建 mock。不需要 wire、dig 或任何第三方 DI 库。

8. Skill 文件:嵌入二进制的 Agent 操作手册

问题 :Agent 拿到 --help 只能看到参数列表,看不到"用户说 X 时应该调哪个命令"、"调这个命令之前需要做什么"。

搬走 :给每个业务领域写一份 SKILL.md,包含意图路由表、前置条件、术语映射。用 //go:embed 嵌入二进制,确保版本一致。

最小可行版本:先写一份,不用 26 个。选一个最常用的业务领域,写清楚三件事------用户说什么 → 调哪个命令、调之前要准备什么、失败了怎么恢复。Agent 拿到这份文件,调用成功率会明显提升。


优先级建议

如果你现在就要做一个 Agent CLI,按这个顺序来:

优先级 做什么 为什么先做 工作量
P0 错误系统(3 种 Category + JSON 信封) Agent 靠错误做决策,这是最高频的交互界面 1-2 天
P0 dry-run 成本最低,收益最大。Agent 可以先预览再执行 半天
P1 命令分层(+ 前缀) 让 Agent 区分"稳定接口"和"原始接口" 半天
P1 输出信封(统一 JSON 格式) Agent 不需要解析多种输出格式 1 天
P2 执行管线(身份+Scope 自动校验) 减少 Agent 在非业务逻辑上的出错 2-3 天
P2 工厂模式 让代码可测试,不然写到第 10 个命令就不敢改了 1 天
P3 身份系统(user/bot + strict mode) 大部分工具一开始只有一种身份 1-2 天
P3 Skill 文件 先写一份,验证有效再铺开 半天

翻完的感受:做 CLI 的三个阶段

阶段 关注点 典型做法
加个 --json 让输出可解析 加一个 format 参数,JSON 输出
结构化错误 让 Agent 能从错误里做决策 错误分类、type/subtype、可执行 hint
Agent 是第二类用户 从头设计 CLI 的接口契约 命令分层、身份系统、Skill 文件、lint 锁死契约、安全护栏

大多数工具停在第一阶段。lark-cli 在第三阶段。

把 Agent 当成第二类用户,这件事从设计原则到代码,中间隔的不是技术能力,是愿不愿意承认------你的 CLI 不是只给人用的。

翻完 17 万行代码,上面这些就是我觉得最值得搬走的东西。肯定还有很多没看到的细节,如果你也在做 Agent CLI,或者发现了好玩的 CLI 设计,欢迎告诉我。


项目地址:github.com/larksuite/c...

相关推荐
卷无止境2 小时前
Eigen 库如何借助 OpenMP 加速计算
c++·后端
羑悻2 小时前
别再只接个 API 了!我用 EdgeOne Makers 手搓了一个“懂业务”的官网售前 AI
后端
卷无止境2 小时前
OpenMPI、MPICH 与 OpenMP:关系、核心概念与架构全解
c++·后端
程序员威哥2 小时前
零基础玩转西门子PLC:C#手撕S7协议,打造工业数据采集神器
后端
用户742837256332 小时前
【Ambari Plus】Step9—AmbariServer 初始化
后端
wuxinzhe76cmd2 小时前
JVM 垃圾回收基础:从 STW 到分代收集(附 G1/ZGC 导读)
后端
MrSYJ2 小时前
TCP协议理解
后端·tcp/ip
boolean的主人2 小时前
超实用!5 个 MySQL 索引优化实战场景(附 10 万测试数据)
后端
BBmmo2 小时前
JDBC基础篇
后端