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

0 阅读17分钟

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

image.png

太长不看: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 APIapi 子命令逃生舱什么都不做,你自己负责一切

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

# 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。这不是"能力递进",是信任递进——越往下,框架替你做的事情越少,你承担的责任越多。

image.png


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

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

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 只做业务逻辑。

image.png


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

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

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

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() 创建,测试环境直接替换字段:

// 测试中 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 信封:

{
  "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什么时候用exitAgent 该做什么
validation参数填错了2params,修参数,重试
authentication没登录 / 没 token3auth login
authorizationtoken 缺 scope3auth login --scope
config本地配置没了3config init
networkDNS / 超时 / 拒绝连接4等一会儿重试
api飞书 API 返回了错误1codelog_id,查文档
policy内容安全拦截6challenge_url,让用户处理
internal工具自己的 bug5停,不要重试,报 bug
confirmation高风险操作没确认10--yes,再跑一次

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

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 直接编译失败
CheckDeclaredSubtypeSubtype 必须是声明的常量,手写字符串过不了 CI
CheckProblemEmbed每个 typed error struct 必须嵌入 errs.Problem
错误重新分类禁止*PermissionError 不能被包成 *InternalError 再往上抛

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

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

image.png


五、身份系统: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

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 嵌入二进制:

## 意图路由
| 用户意图 | 路由到 |
|----------|--------|
| "帮我约个会" | +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 Keychaintoken 不进配置文件,不走环境变量,走系统钥匙串。Agent 读不到,prompt 注入也拿不到
高风险确认risk: "high-risk-write" 的 Shortcut 需要 --yes,否则返回 type: "confirmation"(exit 10)

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

image.png


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

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

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

所有输出都走同一个 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-runvfs + 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 的代码

// 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 的代码

// 不要这样写
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。

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 的语义。

{
  "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 框架。

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 天
P0dry-run成本最低,收益最大。Agent 可以先预览再执行半天
P1命令分层(+ 前缀)让 Agent 区分"稳定接口"和"原始接口"半天
P1输出信封(统一 JSON 格式)Agent 不需要解析多种输出格式1 天
P2执行管线(身份+Scope 自动校验)减少 Agent 在非业务逻辑上的出错2-3 天
P2工厂模式让代码可测试,不然写到第 10 个命令就不敢改了1 天
P3身份系统(user/bot + strict mode)大部分工具一开始只有一种身份1-2 天
P3Skill 文件先写一份,验证有效再铺开半天

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

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

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

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

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


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