翻完 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。它读 type 和 subtype 两个字段就知道该做什么。
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 | 读 code 和 log_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 是第二类用户,不是"人类用户的简化版"。 错误系统需要 type 和 subtype,因为 Agent 靠它们路由。Skill 文件需要意图路由表,因为 Agent 不知道"用户说的'日历'是'日程'"。安全护栏需要更严格,因为 Agent 会被注入。
2. 契约靠代码锁死,不靠文档。 错误分类用 lint 锁死,Subtype 用常量声明锁死,身份校验在管线里锁死。文档会过时,CI 不会。
3. 框架做 Agent 不擅长的事,Shortcut 只做业务逻辑。 身份解析、Scope 预检、参数验证、错误分类、分页合并、内容安全扫描------这些全部在管线里完成。Execute 只拿到一个干净的 RuntimeContext,直接调 API。
4. 最简单的方案往往是最可测试的方案。 工厂模式不用 DI 框架,vfs 抽象不用重量级 mock 库,测试用 t.Setenv 和 t.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 只声明 Scopes、AuthTypes、Flags 和 Execute,剩下的全部由 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 信封,包含 ok、data、meta、_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 设计,欢迎告诉我。