翻完 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 的命令"。
# 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 个阶段:
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 持有所有共享依赖,所有函数字段都是懒加载的:
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。它读 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 不依赖它):
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:
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 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 信封:
{
"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 的代码:
// 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 只声明 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 的语义。
{
"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 天 |
| 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 设计,欢迎告诉我。