对账基线:本文对比的两个对象是 ——
A 侧:WorkBuddy 内置的飞书套件
lark-unified。它是从 Skill 市场安装到本地的一个 Skill 包,_skillhub_meta.json标注name: 飞书套件、version: 1.0.0、source: marketplace、skillId: skill_2053082253572640768。它面向 WorkBuddy 这类 Agent 运行时设计——这一点在它自己的文档里有直接痕迹:setup 脚本用find ~/.workbuddy/skills -name lark_setup.py定位,禁令的理由写着"在 WorkBuddy 里会输出坏掉的二维码",脚本 docstring 也点名works in non-TTY environments (e.g. WorkBuddy, CI, headless shells)。B 侧:GitHub 仓库 larksuite/cli
main分支下的skills/目录(27 个 Skill,快照抓取于 2026-08-01)。文中所有"上游真实情况"的判定,均以该仓库的 Go 源码为准(
cmd/config/、shortcuts/*/、internal/core/workspace.go、content_embed.go),而非以文档描述为准。
为什么这个对比值得写
这两个东西表面上做同一件事:教 AI Agent 怎么用 lark-cli 操作飞书。它们甚至封装的是同一个 CLI 二进制——WorkBuddy 的飞书套件在 setup 里跑的就是 npm install -g @larksuite/cli,也就是 B 侧那个仓库的发行版。
区别在于:B 侧是 CLI 作者自己写的 Skill,A 侧是第三方(WorkBuddy 侧)为同一个 CLI 重新封装的一层 Skill。 一个是原产地随货附带的说明书,一个是转售方自己重写的使用手册。
把两边的文件摊开数一下,差距是量级的:lark-unified 是 16 个文件、约 1.3k 行 Markdown、1 个 Skill 单元;larksuite/cli/skills 是 458 个文件、57.5k 行 Markdown、27 个 Skill 单元。43 倍的内容体量差。
如果只看这个数字,很容易得出"官方更全面"这种没营养的结论。真正有意思的问题是:这 43 倍的差距花在哪了?如果我只想让 Agent 能发条消息、读个表格,那 1.3k 行是不是就够了?
我把两边逐文件读完、并把 lark-unified 声称的命令拿去和上游 Go 源码逐条对账之后,答案比预想的更明确:这不是"详细程度"的差别,是"契约由谁保证"的架构差别。而且 A 侧已经出现了 8 处可验证的命令漂移——WorkBuddy 飞书套件文档里写的命令,在现在的 lark-cli 上跑不出来。
这个结论对 WorkBuddy 的使用者是有实际影响的:当 Agent 按套件文档去跑 lark-cli config view 而失败时,它很可能把原因归到"没配置好"或"权限不足",然后开始一轮无效重试——而真实原因只是这个子命令从来没存在过。
下面按架构设计、功能模块、代码实现、配置方式、安全模型、扩展性六个维度展开。
一、架构设计:一次扇出 vs 逐级路由
A 侧:WorkBuddy 飞书套件——单入口扇出,扁平两层
WorkBuddy 装完这个套件后,落到磁盘上的结构非常简单:
lark-unified/ # 安装于 ~/.workbuddy/skills/(或 ~/.aimea/skills/)
├── SKILL.md # 296 行,唯一入口,收口 11 个业务域
├── references/ # 扁平一层,12 份分域文档
│ ├── lark-im.md # 112 行
│ ├── lark-base.md # 118 行
│ ├── lark-shared.md # 230 行(认证与权限)
│ └── ...
├── scripts/
│ └── lark_setup.py # 215 行,自带认证脚本
├── _skillhub_meta.json # 市场元信息:skillId / installedAt / examples_zh
└── _icon.svg
_skillhub_meta.json 里还带了三条中英文触发示例,这是市场型 Skill 特有的字段:
{
"name": "飞书套件",
"source": "marketplace",
"examples_zh": [
"用飞书给团队群发一条消息",
"在飞书云文档新建一篇文档",
"查询飞书多维表格里的记录"
]
}
入口 SKILL.md 承担了三件事:全域能力索引、全局约定(身份、分页、输出格式)、以及一段前置的 setup 规则。11 个域的介绍在里面依次排开,每个域给出「一句话职责 + 常用 shortcut 列表 + 一个 references 跳转」。
这个结构的好处是心智负担极低:一个文件读完就知道这套 Skill 能干什么。坏处在下面几节会逐个暴露。
B 侧:领域路由网络,三层披露
官方的组织方式是完全不同的思路:
skills/
├── lark-shared/ # 认证基座,253 行,被 27 个 skill 强制引用
├── lark-im/ # 59 文件 / 5.6k 行
│ ├── SKILL.md
│ └── references/
│ ├── lark-im-messages-send.md # 一个 shortcut 一份文档
│ └── card/
│ ├── card-2.0-schema.md
│ └── components/ # 29 个卡片组件各一份
│ ├── button.md
│ ├── table.md
│ └── ...
├── lark-whiteboard/ # 30 文件 / 4.9k 行
│ ├── routes/ # 按输入形态分流:dsl / mermaid / svg / svg-edit
│ ├── scenes/ # 15 种图表场景各一份:泳道、漏斗、鱼骨、飞轮…
│ └── elements/ # 元素级 schema:连接线、排版、样式
├── lark-base/ # 27 文件 / 8.0k 行,26 份专题 references
└── ... 共 27 个
三个关键设计:
1. 共享基座强制前置。几乎每个 SKILL.md 的第一行正文都是同一句硬话:
CRITICAL — 开始前 MUST 先用 Read 工具读取
../lark-shared/SKILL.md,其中包含认证、权限处理
认证、身份选择、scope 处理、JSON 契约、风险门禁协议——这些跨域共性只在一处实现,27 个 Skill 共享。改一次,全部生效。
2. description 承担负向路由。这是我认为官方实现里最见功力的一点。frontmatter 的 description 不只写"我能干什么",还明确写"我不干什么、该去哪":
# lark-calendar
description: "飞书日历:管理日历日程和会议室。……当用户需要查看日程安排、创建/修改会议、
查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc)、待办任务(走 lark-task)。"
# lark-vc
description: "飞书视频会议:搜索历史会议记录、查询会议纪要……查询未来日程走 lark-calendar。
不负责:Agent 真实入会/离会、会中实时事件(走 lark-vc-agent)。"
# lark-approval
description: "飞书审批:……审批待办不是飞书任务;非审批类待办走 lark-task。
不负责创建审批定义;三方审批定义不走原生提单。"
这是在用 description 做可判定的转诊规则。日历和视频会议在语义上高度接近(都是"会"),Agent 极容易走错域;官方的处理不是靠模型自己悟,而是在两个 Skill 的描述里互相指路。lark-unified 的单体结构里没有这个概念——因为所有域都在一个文件里,压根不存在"路由到哪个 Skill"的问题,但也就失去了"这件事我不该做"的边界感。
3. 版本各自演进。lark-sheets 已到 3.0.2,lark-doc 是 2.0.0,lark-base 是 1.2.3,lark-approval 是 1.2.0,lark-attendance 还是 1.0.0。表格域迭代最快,考勤域几乎不动——这是符合真实演进节奏的。lark-unified 整包只有一个 1.0.0,任何一个域的修订都是全量变更,也就无法表达"哪个域在快速迭代"这个信息。
二、内容分发链路:谁保证「文档写的」等于「CLI 能跑的」
这是两种实现最本质的工程差异,也是我认为最值得单独拎出来讲的一节。
A 侧:WorkBuddy 侧内容与飞书侧二进制解耦
lark-unified 的链路横跨两个组织:WorkBuddy 侧人工读上游 → 提炼成 Markdown → 发布到 Skill 市场 → 用户安装到 ~/.workbuddy/skills/;飞书侧则通过 npm 独立发布 CLI,用户再用 npm install -g @larksuite/cli 装上。
注意这个跨组织的裂缝:Skill 内容和 CLI 二进制是两条独立的分发线,且分属两个团队。Skill 的内容冻结在写作那一天,CLI 由飞书团队通过 npm 一直往前走。中间没有任何机制校验两者是否还对得上——WorkBuddy 侧没有渠道感知上游改了什么,上游也不知道有人在外面维护着一份自己的说明书。
SKILL.md 里 setup 步骤写的是:
lark-cli --version 2>/dev/null || npm install -g @larksuite/cli
拉的是 latest,版本不锁。
B 侧:go:embed 编译期锁定
官方的做法是把 Skill 内容编译进 CLI 二进制。content_embed.go 是这套机制的全部:
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
// a new content type is omitted until added to the embed list.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
var embeddedContentFS embed.FS
三个细节值得注意:
白名单而非黑名单。注释明确写了 “It’s a whitelist — a new content type is omitted until added to the embed list”。新增内容类型如果不登记,默认不下发。assets/ 和 scripts/ 被刻意排除——机器资源不该进模型上下文。
降级而非崩溃。装配失败只 warning,不 panic:
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
} else {
cmd.SetEmbeddedSkillContent(sub)
}
注释里的原话是 “embedded content is nice-to-have, not load-bearing”。内容缺失时 CLI 主体功能照常可用。
Agent 通过命令读内容,而非猜文件路径。cmd/skill/skill.go 提供了 lark-cli skills list 和 lark-cli skills read:
lark-cli skills list # 所有 skill 的 name/description/version
lark-cli skills list lark-doc/references # 像 ls 一样列一层
lark-cli skills read lark-doc # 读 SKILL.md(原始 markdown)
lark-cli skills read lark-doc references/lark-doc-fetch.md
lark-cli skills read lark-doc --json # JSON 信封
而且 readGuidance() 还处理了一个很实际的细节——跨 skill 引用的路径改写:
func readGuidance(name string) string {
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
}
因为路径守卫会拒绝字面量 ../,所以文档里的 ../lark-shared/SKILL.md 需要被改写成 skills read lark-shared/SKILL.md。这段 guidance 走 stderr,stdout 保持与文件字节一致——不污染管道输出。
还有一层漂移哨兵。internal/skillscheck/ 维护 skills-state.json:
type SkillsState struct {
Version string `json:"version"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
AddedOfficialSkills []string `json:"added_official_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
UpdatedAt string `json:"updated_at"`
}
当本地安装的 skill 版本与正在运行的二进制版本不一致时,CLI 会在 JSON 输出里挂上 _notice.skills,提示跑 lark-cli update。AGENTS.md 里对这个字段的语义有明确定义:冷启动(从未同步过)时 current 为空串,漂移时是版本号字符串。
三、契约漂移实测:把文档拿去和源码对账
上一节说的"没有机制保证同步"不是理论风险。我把 lark-unified 里出现的每一条命令都拿去和上游 main 分支源码核对,结果如下。
3.1 config 子命令:5 处失效
lark-unified 的 references/lark-shared.md 和 SKILL.md 里出现了这些命令:
lark-cli config show # SKILL.md setup 步骤用它检测是否已配置
lark-cli config view # "Verify setup is complete"
lark-cli config use-config dev # "Switch between configs"
lark-cli config set-default # 被列入 FORBIDDEN 清单
lark-cli config import-env # 安全最佳实践段落
对照 cmd/config/ 目录下真实注册的子命令(从各文件的 Use: 字段提取):
| 文档写的 | 上游真实情况 |
|---|---|
config show |
✅ 存在 |
config view |
❌ 未注册 |
config use-config <name> |
❌ 不存在;多环境走全局 --profile |
config set-default |
❌ 已更名为 config default-as [user|bot|auto] |
config import-env |
❌ 未找到 |
其中 config set-default 这条特别有意思——它被 lark-unified 郑重列入 FORBIDDEN — never run these commands under any circumstances。一条禁令指向了一个已经不存在的命令。这恰好说明这份文档是某个历史时点的快照。
config default-as 的实现在 cmd/config/default_as.go,语义也变了——现在支持 auto 这个第三态:
cmd := &cobra.Command{
Use: "default-as [user|bot|auto]",
Short: "View or set default identity type",
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
...
}
而 lark-unified 的 SKILL.md 里写的是 **Default identity**: --as bot。上游现在的默认是 auto——由 CLI 按命令语义自动选身份。这个差异会直接导致 Agent 对权限失败的归因走偏。
3.2 配置文件路径与格式:双错
references/lark-shared.md 有这么一段:
Lark CLI stores configuration in `~/.lark/config.toml`:
[default]
tenant_key = "xxxxxxx"
app_id = "xxxxxxx"
app_secret = "xxxxxxx"
[dev]
...
Switch between configs: `lark-cli config use-config dev`
上游 internal/core/workspace.go 的真实实现:
// Priority: LARKSUITE_CLI_CONFIG_DIR env → ~/.lark-cli.
...
return filepath.Join(home, ".lark-cli")
目录名是 .lark-cli 不是 .lark;我在 internal/core/ 和 internal/registry/ 里都没搜到 config.toml 或 .toml 的引用。文档给出的 TOML 片段和 use-config 用法都无法落地。排障时按这个路径去找文件,只会更困惑。
3.3 Shortcut 名称:多个域集体失配
lark-unified 在各域介绍里列了大量 shortcut。我把 shortcuts/*/ 下所有 "+xxx" 字面量提取出来做对照,问题集中在这几个域:
| 文档写的 | 上游真实的 |
|---|---|
contact +me |
contact +get-user |
contact +users-search |
contact +search-user |
contact +departments-list |
不存在(contact 只有 +get-user / +search-user / +search-bot) |
sheets +spreadsheets-read |
sheets +cells-get |
sheets +spreadsheets-append |
sheets +cells-set |
sheets +spreadsheets-create |
sheets +workbook-create |
sheets +spreadsheets-find |
sheets +cells-search |
base +tables-records-list |
base +record-list |
base +tables-records-create |
base +record-batch-create |
base +fields-list |
base +field-list |
wiki +spaces-create |
wiki +space-create |
wiki +wiki-pages-create |
wiki +node-create |
task +tasks-create / +tasks-list |
task +create / +get-my-tasks |
doc +documents-create / +documents-list |
docs +create / +search |
drive +files-upload / +files-download |
drive +upload / +download |
lark-unified 的命名普遍带复数资源前缀(+spreadsheets-read、+tables-records-list),而上游实际收敛成了单数领域词 + 动作(+cells-get、+record-list)。这看起来像是上游做过一轮命名规范化,而 Skill 快照留在了规范化之前。
顺带一提,lark-unified 里 SKILL.md 的"Quick Example"章节四个示例——发消息、搜消息、建表格加数据、查 base 记录——除了 im +messages-send 和 im +messages-search 之外,其余全部无法直接执行。
3.4 输出格式参数:形态已变
文档写的是四个独立开关:
- `--table`: Format output as ASCII table
- `--csv`: Export as CSV
- `--yaml`: YAML format
- `--raw`: Unformatted raw output
上游统一收敛为单个 --format 参数(见 internal/output/format_type.go):
--format json # 默认,完整 JSON 信封
--format pretty # 人类可读
--format table # 表格
--format ndjson # 换行分隔 JSON,便于管道
--format csv # CSV
没有 yaml,没有 raw,且四个 bool flag 变成了一个 string flag。
3.5 引用断链:6/16
这条不需要对照上游,在 Skill 内部就能验证。SKILL.md 里一共引用了 16 个 references/*.md,实际存在 12 个:
MISS references/events.md ← "Event Subscriptions" 章节指向它
MISS references/lark-contact.md ← 通讯录域的"full reference"
MISS references/lark-vc.md ← 视频会议域的"full reference"
MISS references/openapi.md ← "OpenAPI Discovery" 章节
MISS references/skill-maker.md ← "Custom Skills & Integrations" 章节
MISS references/workflows.md ← "Workflows" 章节
而实际存在的 12 份里,还有一份是模板占位符没删——references/api_reference.md:
# Reference Documentation for Lark Unified
This is a placeholder for detailed reference documentation.
Replace with actual reference content or delete if not needed.
Example real reference docs from other skills:
- product-management/references/communication.md - ...
这份文件没有被 SKILL.md 引用,是脚手架残留。加上 6 个断链,references/ 目录的有效率是 11/13。
这些漂移说明什么? 不是"谁写得不认真"。lark_setup.py 里对 slow_down 做退避、把 HTTP 400 当 OAuth 正常态解析这些细节,说明作者是懂行的。问题是范式本身:把上游能力抄成独立快照,就必然要为同步付出持续人力,而持续人力不会发生。官方用 go:embed 把内容钉在二进制上,本质是用编译期约束替代了"记得更新文档"这条注定失效的人类流程。
四、上下文预算与渐进式披露
Skill 设计的真实约束不是"写得全不全",而是每一次任务要为多少无关内容付费。
A 侧的死结
lark-unified 的入口是 296 行,覆盖 11 个域,平均每域约 20 行余量。这个余量只够写「有哪些 shortcut」的目录级信息,写不下「参数怎么填、什么时候会踩坑、失败了怎么退避」。
而后者恰恰是 Agent 真正会失败的地方。
于是就形成一个死结:要补细节就得加长入口 → 每次任务都为无关内容付费;不加就只能靠模型猜 → 猜错了没有兜底。而且无论发消息还是配 Base 工作流,入口成本都是恒定的 296 行——这个成本与任务复杂度完全无关。
B 侧:把成本转成条件成本
官方的解法是必读清单。看 lark-doc/SKILL.md 的"前置条件"章节,它不讲细节,只给一张判定表:
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
1. `../lark-shared/SKILL.md` — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(docs +fetch)** → 必读 lark-doc-fetch.md
3. **创建或编辑文档内容** → 必读 lark-doc-xml.md 和 lark-doc-style.md;
从零创建时加读 lark-doc-create-workflow.md;
编辑已有文档时加读 lark-doc-update.md 和 lark-doc-update-workflow.md
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
三个特征:条件化(做 X 才读 Y)、可判定(不需要模型主观判断"够不够了")、带后果说明(说清不读会出什么错,而不是空喊"请阅读")。
三层披露以 lark-whiteboard 为例最清楚:
- L1
SKILL.md:只做路由,判断该走 DSL、Mermaid 还是 SVG,以及是否要隔离到 SubAgent。这是唯一常驻上下文的一层。 - L2
routes/:按输入形态分流成dsl.md/mermaid.md/svg.md/svg-edit.md,确定路线后才加载对应一份。 - L3
scenes/+elements/:15 个图表场景各一文件(泳道、漏斗、鱼骨、飞轮、树图、里程碑、组织架构……),画什么才读什么。
lark-im 的 59 个文件只有 5.6k 行,平均每文件不到 100 行——因为它把 29 个卡片组件各拆一份。Agent 要画一个按钮,就只读 button.md,不必把整套 Card 2.0 Schema 拖进上下文。
规模分布本身也有信息量:
- 头部两域吃掉 28% 内容。
lark-drive(8127 行)和lark-base(8033 行)各占 8k 行,因为它们是"结构最复杂、参数最容易填错"的域:字段类型、公式、lookup、权限角色、导入导出格式。复杂度没有被抽象掉,而是被显式沉淀。 - 最重要的 Skill 反而最薄。
lark-shared只 253 行,却被 27 个 Skill 强制引用。它薄是必须薄——这是每次任务都要付的固定成本,多写一行就是给所有域的所有调用都加一行税。 - 简单域就该简单。
lark-attendance只有 57 行 1 个文件。没有为了架构一致性而强行拆分。
五、配置与认证:都在解 TTY 问题,解法层次不同
Agent 运行环境没有 TTY,而飞书应用注册和登录都是 device flow(需要用户在浏览器里点确认)。两边都在解这个问题。
这一节也是最能看出 A 侧真实处境的地方:WorkBuddy 侧遇到的是一个具体而紧迫的工程障碍(在自家运行时里二维码会渲染坏、交互式命令会挂住),而它手上只有"改 Skill 内容"这一个杠杆——改不了 CLI。于是只能把整个协议在 Skill 里重写一遍。
A 侧:在 Skill 里复刻整个协议(因为改不了 CLI)
scripts/lark_setup.py 215 行,文件头的 docstring 直接点名了 WorkBuddy:
"""
lark_setup.py - Lark CLI auto-setup script
Implements the same device flow as `lark-cli config init --new` but
works in non-TTY environments (e.g. WorkBuddy, CI, headless shells).
"""
它直接打 accounts.feishu.cn/oauth/v1/app/registration,begin 拿 device_code,然后循环 poll。有两处写得确实到位:
把 HTTP 400 当正常态。飞书用 400 承载 OAuth 语义错误,脚本在 HTTPError 分支里解析 body 而不是直接抛:
except urllib.error.HTTPError as e:
# Feishu returns OAuth errors (e.g. authorization_pending) as HTTP 400
raw = e.read()
try:
return json.loads(raw)
except Exception:
raise RuntimeError(f"HTTP {e.code}: {raw.decode(errors='replace')}")
对 slow_down 做退避:
elif err == "slow_down":
current_interval = min(current_interval + 5, 60)
continue
SKILL.md 里还专门写了一段告诉 Agent「400 是正常的,别重跑 begin,复用已有 device_code」——这是踩过坑才会写的话。
但这个方案的隐患是结构性的:endpoint 常量、跟踪参数(lpv / ocv / from=cli)、Lark 国际版的品牌重试逻辑,全都是对 Go 侧实现的手工复刻。注释自称 “mirrors internal/core/endpoints.go”——mirror 这个词本身就说明它是副本。上游改一次,这份副本就静默失效。
对交互式命令,lark-unified 用的是纯文本禁令:
**FORBIDDEN — never run these commands under any circumstances:**
- `lark-cli config init --new`
- `lark-cli config init` (interactive)
- `lark-cli config set-default`
These require a TTY, output a broken QR code in WorkBuddy, and must never be used.
理由写得很实在(在 WorkBuddy 里会输出坏掉的二维码),但如前所述,第三条指向的命令已不存在。
B 侧:把"不阻塞"做成 CLI 一等能力
官方的解法是 split-flow:CLI 原生支持把 device flow 拆成两次调用。
# 第 1 轮:发起,立即返回,不阻塞
lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
# → 返回 verification_url + device_code
# 生成二维码给用户
lark-cli auth qrcode <verification_url> --output "x.png"
# ——本轮结束,交还控制权,等用户回复——
# 第 2 轮:由 Agent 亲自执行收口
lark-cli auth login --device-code <device_code>
Skill 不实现协议,只负责教 Agent 正确编排两个已有子命令。而 lark-shared/SKILL.md 对这个流程的解释,揭示了一个比"没有 TTY"更深一层的问题:
拿到
verification_url后,将它原样作为本轮最终消息发给用户,并结束本轮/交还控制权。不要在同一轮中展示 URL 后立刻执行--device-code阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。
这不是协议问题,是 Agent 运行时的信息可见性问题。很多 harness 不会把工具调用的中间输出实时展示给用户,只呈现最终回复。如果 Agent 打印完 URL 就进入阻塞轮询,用户在超时前根本看不到那个 URL。lark-unified 的脚本是单进程阻塞式的,没有处理这个场景。
配套的硬规则也很密:
- 你必须亲自执行
--device-code,不要指示用户自己跑 - 禁止缓存
verification_url/device_code,每次都重新生成 - bot 身份禁止跑
auth login,缺权限就把错误里的console_url原样交给用户去开发者后台开 scope
最后一条是身份纠错护栏。bot 的权限来自应用 scope(后台配置),user 的权限需要后台 scope + 用户授权两层。这两条路径完全不同,Agent 很容易在两种身份间盲目重试。官方直接把这条写成禁令。
配置方式对照表
| 维度 | WorkBuddy 飞书套件 lark-unified | larksuite/cli 官方 skills |
|---|---|---|
| 配置目录 | 文档写 ~/.lark/config.toml(错误) |
~/.lark-cli,可用 LARKSUITE_CLI_CONFIG_DIR 覆盖 |
| 多环境切换 | config use-config <name>(不存在) |
全局 --profile |
| 默认身份 | 文档写死 --as bot |
config default-as [user|bot|auto],默认 auto |
| 应用注册 | 自带 Python 脚本复刻 device flow | config init --new(后台运行提取 URL) |
| 登录授权 | 未涉及 auth login 系列 |
auth login --domain / --scope / --recommend / --no-wait / --device-code |
| 授权范围 | 手工维护一张 15 行 scope 表 | auth scopes 列全量;auth check 单 scope 校验(exit 0/1) |
| 登录态查询 | config view |
auth status --json --verify、whoami |
| 二维码 | 脚本里 open / xdg-open 打开浏览器 |
auth qrcode(PNG / ASCII),Skill 里强制要求生成 |
| 噪声抑制 | 未涉及 | LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 / ..._NO_SKILLS_NOTIFIER=1 |
最后一行值得说一句。官方 CLI 会在 JSON 输出里挂 _notice(更新提示、skills 漂移提示),这对机器解析是噪声。lark-shared 给出了两个环境变量来抑制,并配了一条行为规则:
除非用户正在询问更新、版本或 notice,否则不要把
_notice原样复制为当前任务的主要答案,也不要为了 notice 中断当前任务去反复查 help。
这是很典型的"为 Agent 写文档"的思路——预判模型会被无关信息带偏,提前给出处理规则。
六、安全模型:文本劝导 vs 运行时门禁
lark-unified 的 references/lark-shared.md 有一节 “Security Best Practices”,内容是对的:不提交凭据、用环境变量、最小 scope、轮换密钥、校验不可信输入。但全部是文本层面的建议。
官方实现在这一维上多了一整套运行时约束。
exit 10 强制确认门禁
对高风险写操作(risk: "high-risk-write"),不带 --yes 调用时 CLI 直接以退出码 10 拒绝执行,并在 stderr 返回结构化信封:
{
"ok": false,
"identity": "bot",
"error": {
"type": "confirmation",
"subtype": "confirmation_required",
"message": "drive +delete requires confirmation",
"hint": "add --yes to confirm",
"risk": "high-risk-write",
"action": "drive +delete"
}
}
lark-shared/SKILL.md 给出了完整的四步处理协议:
- 识别:exit code = 10 且
error.type == "confirmation"→ 这不是普通错误,不许当失败放弃 - 上报:把
error.action、error.risk和关键参数摊给用户,明确说"这是高风险操作",等显式同意 - 同意 → 在原始 argv 末尾追加
--yes重试 - 拒绝 → 终止,不得改写参数绕过
以及一份"绝对不允许"清单:
- 看到 exit 10 就默认加
--yes静默重试(这等于禁用门禁)- 把
confirmation_required当网络错误/权限错误处理- 在用户没明确同意的前提下追加
--yes重试- 用
sh -c等 shell 方式拼接命令重试——用exec.Command(argv...)参数数组形式,避免 shell 解析把用户参数当作语法
最后一条是防命令注入。Agent 拼 shell 字符串重试时,用户参数里的引号、分号、反引号都可能被 shell 解析。官方直接把"用 argv 数组"写成硬要求。
lark-unified 里完全没提 exit 10 的存在。这是个实质缺口:Agent 遇到会当成普通失败——要么放弃任务,要么盲加 --yes 重试。
其余几层(均为官方独有)
路径逃逸拦截:
--file、--output、--output-dir、@file等路径参数只接受 cwd 下的相对路径,传绝对路径会报unsafe file path。数据输入(@file、大 JSON)优先用 stdin 传入。
这条同时解决了两个问题:限制 Agent 的文件系统触达范围,以及避免路径转义问题。
成功判定契约。这条很容易被忽略但后果严重:
判断成功必须用
ok == true(或进程退出码 0),不要用code == 0:成功信封没有顶层code/msg字段,code只出现在错误信封的error内。按 OpenAPI 老格式{"code": 0, "msg": "ok"}判断会把所有成功调用误判为失败;封装写入类命令(如task +create)时尤其危险,误判会绕过幂等逻辑导致重复创建。
熟悉飞书 OpenAPI 的人会条件反射去看 code == 0,而 CLI 换了信封格式。这段文档就是专门拦这个坑的。
风控信号与开关。默认随 OpenAPI 请求上报最小风控信号(OS 类型 + 机型)用于识别异常调用,可按工作区关闭:
lark-cli config risk-control off # 关闭
lark-cli config risk-control on # 开启
lark-cli config risk-control default # 恢复默认
README 的风险声明也写得很直接:“我们强烈建议你不要主动修改任何默认安全设置;一旦放宽相关限制,风险将显著增加,后果由你承担”,以及"建议把这个机器人当私人助手用,不要拉进群,避免权限滥用或数据泄露"。
七、扩展性:加一个新能力要动几处
A 侧:唯一扩展点是加长入口
给 lark-unified 加一个域,能做的只有:往 SKILL.md 再加一节 + 往扁平的 references/ 再放一个文件 + 整包 version 一起动。
缺失的扩展设施:
- 没有 Skill 之间的依赖声明(无
requires/siblings语义) - 没有
cliHelp之类的"能力自查入口",Agent 无法自行核对命令是否存在 - 没有第三层目录约定,重型知识(卡片组件、图表场景)无处安放
- 没有构建期检查,新增引用写错也不会有人知道(现有 6 个断链就是证据)
B 侧:一条有配套设施的流水线
官方提供了三个"元 Skill"来支撑扩展:
lark-skill-maker 给的不只是模板,还是一条优先级铁律:
Shortcut > 已注册 API > lark-cli api 裸调
先查 --help 和 schema,确实没有封装才允许打穿到原生 OpenAPI。避免每个新 Skill 都重造轮子。它给出的 SKILL.md 模板还带 frontmatter 规范:
---
name: lark-<name>
version: 1.0.0
description: "<功能描述>。当用户需要<触发场景>时使用。"
metadata:
requires:
bins: ["lark-cli"]
---
并明确"description 决定触发 — 包含功能关键词 + ‘当用户需要…时使用’"。
lark-openapi-explorer 负责在现有封装不够用时,从官方文档库逐层挖原生 OpenAPI 接口,拿到完整方法、路径、参数和权限,再用 lark-cli api 裸调。
skill-template/ 目录还有 13 个域模板 + 一份 master template,是给贡献者的起手式。
frontmatter 的 metadata 也是可扩展的依赖声明位:
# lark-sheets
metadata:
requires:
bins: ["lark-cli"]
siblings: ["lark-shared"] # 声明依赖的兄弟 skill
cliHelp: "lark-cli sheets --help" # 给 Agent 的自查入口
# lark-apps
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli apps --help; lark-cli apps +<cmd> --help"
cliHelp 这个字段挺妙——它让 Agent 在文档和现实不一致时有办法自行核对。这正是 lark-unified 缺的那道保险。
还有两个工作流 Skill 作为编排范例:lark-workflow-meeting-summary 和 lark-workflow-standup-report。后者只有 122 行,但把编排该写什么示范得很完整:
{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表
└─► task +get-my-tasks --complete=false [--due-end] ──► 未完成待办
│
▼
AI 汇总(时间转换 + 冲突检测 + 排序)──► 摘要
里面有一条特别典型的"gotcha 沉淀":
+get-my-tasks不带--complete时会同时返回已完成和未完成任务,会把已完成任务当成"待办"展示进摘要里。站会/日报这种 pending 汇总场景必须显式带上--complete=false,不要省略。
以及 --start / --end 只吃 ISO 8601,不认 "tomorrow" / "next monday",需要 Agent 自己算日期。这些都是"跑过才知道"的知识,写进 Skill 才有价值。
重型脚本随 Skill 下发
官方 Skill 里的 scripts/ 目录规模值得单独提一下:
| 脚本 | 体积 | 用途 |
|---|---|---|
lark-slides/scripts/xml_text_overlap_lint.py |
108 KB | 幻灯片文字重叠检测 |
lark-slides/scripts/sxsd_validator.py |
33 KB | 幻灯片 XML 结构校验 |
lark-doc/scripts/doc_word_stat.py |
39 KB | 文档字数统计(统一口径) |
lark-slides/scripts/iconpark_tool.py |
12 KB | 图标检索 |
lark-sheets/scripts/sheets_df.py |
1 KB | 表格转 DataFrame |
这些脚本被 go:embed 刻意排除——不进上下文,但随发行版落到磁盘供 Agent 调用。这是很清晰的分层:知识进上下文,工具进文件系统。
lark-doc 对 doc_word_stat.py 的用法说明也体现了这个思路:
用户需要统计文档的总字数时,先读
lark-doc-word-stat.md,并按其中流程调用scripts/doc_word_stat.py;统计口径以该脚本为准,不要改用其他方式自行计算。
把"口径一致性"这种要求交给确定性脚本,而不是让模型每次自己数。这是把 Agent 不擅长的事外包出去的正确姿势。
八、六维对比总表
| 维度 | WorkBuddy 飞书套件 lark-unified(第三方封装 · 单体聚合) | larksuite/cli skills(CLI 作者自维护 · 领域拆分 + 内嵌) |
|---|---|---|
| Skill 单元数 | 1 | 27 |
| 文件数 / MD 行数 | 16 / ~1.3k | 458 / 57.5k |
| 拓扑 | 单入口 + 扁平 references(2 层) | 领域路由网络 + 三层披露 |
| 入口成本 | 恒定 296 行,与任务无关 | 253 行共享基座 + 命中域按需加载 |
| 域间路由 | 无(全在一个文件里) | description 负向路由 + 显式转诊 |
| 版本管理 | 整包单一 1.0.0 |
每域独立演进(1.0.0 ~ 3.0.2) |
| 内容分发 | 市场安装到本地目录 | go:embed 编译进二进制 |
| 契约同步 | 人工,无校验 → 实测 8 处漂移 | 编译期锁定 + skills-state.json 哨兵 |
| 内容读取 | 宿主 Read 工具读文件路径 | lark-cli skills list / read |
| 认证实现 | Skill 内 215 行 Python 复刻 device flow | CLI 原生 split-flow(--no-wait / --device-code) |
| harness 感知 | 无(单进程阻塞轮询) | 有(明确要求分轮交还控制权) |
| 配置命令准确度 | 5/6 失效 | — |
| 默认身份 | 文档写死 --as bot |
default-as auto(CLI 自动选) |
| 高危操作约束 | 文本建议"请确认" | exit 10 硬门禁 + 四步协议 + 禁令清单 |
| 路径安全 | 未涉及 | 只接受 cwd 相对路径,绝对路径报错 |
| 成功判定契约 | 未涉及 | 强制 ok == true,禁用 code == 0 |
| 引用完整性 | 6/16 断链 + 1 份模板残留 | 交叉引用受 embed 与 CI 约束 |
| 扩展设施 | 无(只能加长入口) | skill-maker + openapi-explorer + 13 个域模板 |
| 依赖声明 | 无 | metadata.requires.bins / siblings + cliHelp |
| 重型工具 | 1 个脚本(认证) | 5+ 脚本随发行版下发,排除出上下文 |
| 启动成本 | 极低,一个目录拷进去就能用 | 需要完整构建链路 / 装官方发行版 |
九、给实践者的判据
抛开这两个具体对象,把它抽象成通用的 Skill 设计问题,我认为有三个必答问题。
问题一:你能控制被封装的那个 CLI / API 吗?
能控制 → 同仓 + 编译期内嵌。让 CI 保证契约,别靠人记得改。这是官方实现最值得抄的一点,且这个模式不限于 Go:Python 可以用 importlib.resources,Node 可以打进 package 并做版本断言。核心是让"文档与实现不一致"变成一个构建期或运行期能被发现的错误,而不是一个只有用户踩坑时才暴露的问题。
不能控制(比如你在封装第三方 SaaS)→ 必须建对账机制。可选路径:
- 钉死被封装工具的版本(
npm install -g @larksuite/cli@1.2.3),而不是 latest - 在 Skill 里放一条自查命令(学官方的
cliHelp),让 Agent 在失败时能自行核对命令是否存在 - 定期跑
--helpdiff,把输出与文档做机械比对 - 至少在 Skill 里写明"本文档基于 X 版本编写",让漂移可归因
WorkBuddy 的飞书套件正好处在"不能控制"这一类——它封装的是飞书团队独立发布的 CLI,却四条都没做,所以漂移无声无息地累积了。
问题二:覆盖面超过 3 个业务域了吗?
超过 → 拆域 + 共享基座。认证只写一份,域间用负向描述互相转诊。判断标准是:如果你发现自己在入口文件里为每个域只能挤出 20 行,那就是该拆了。
没超过 → 单文件反而更好。别为了架构而架构,拆分本身有心智成本和维护成本。官方 lark-attendance 只有 57 行 1 个文件就是这个道理——简单域就该简单。
问题三:这个 Skill 会触发不可逆操作吗?
会 → 约束必须落到运行时。退出码门禁、路径校验、确认协议,都要在被调用的那一侧实现。文档里的"请谨慎"只能作为补充说明,不能作为唯一防线。
理由很简单:Agent 的行为是概率性的。任何依赖"模型会自觉遵守"的安全设计,在足够多次调用后都会失效一次。而不可逆操作只需要失效一次。
lark-unified 写了"写入/删除操作前必须确认用户意图",这句话是对的,但它是建议。官方的 exit 10 是过不去。这两者在一次调用里看不出区别,在一万次调用里区别就是有没有出过事故。
十、如果你现在正在用 WorkBuddy 的飞书套件
上面的分析对 WorkBuddy 使用者有几条可以直接落地的推论。
1. 遇到命令报"不存在",先怀疑文档而不是环境。 本文第三节列出的 8 类漂移都可以直接对照:config view、config use-config、config import-env、contact +me、sheets +spreadsheets-read、base +tables-records-list、--yaml / --raw、以及 ~/.lark/config.toml 这个路径。碰到这些别去反复调环境。
2. 用官方发行版自带的 Skill 覆盖套件文档。 装完 CLI 后可以直接问它自己:
lark-cli skills list # 现网真实有哪些 skill、什么版本
lark-cli skills read lark-shared # 认证与权限的权威版本
lark-cli skills read lark-sheets # 表格域真实的 shortcut 清单
lark-cli <service> --help # 单域 shortcut 自查
lark-cli schema <service>.<resource>.<method> # 参数结构自查
这几条命令读出来的内容和二进制同版本,永远不会漂。把它们当作事实来源,套件文档当作导航目录,是当前最省事的用法。
3. 补上套件没教的两条硬约束。 一是高危写操作的 exit 10 协议——看到退出码 10 且 error.type == "confirmation" 时要向用户确认后再追加 --yes,不能静默重试也不能当失败放弃;二是成功判定必须看 ok == true 而不是 code == 0,否则封装写入类命令时会误判失败并重复创建。
4. 认证优先走 CLI 原生的 split-flow。 套件自带的 lark_setup.py 是单进程阻塞式的,在不透传中间输出的运行时里用户可能看不到授权链接。改用 auth login --no-wait --json 拿 URL、结束本轮、下一轮再用 --device-code 收口,成功率更高。
5. 如果你在维护这类第三方套件,最小成本的改造是给每个域加一个 cliHelp 字段和一句"本文档基于 CLI vX 编写"。这两条不需要架构改动,但能把"文档骗了 Agent"变成"Agent 能自己发现文档过时了"。
十一、最后:几个可以直接借走的细节
不论你在写什么领域的 Skill,官方实现里有几个做法是通用的:
1. description 写负向路由,不只写正向能力。 “不负责 X,走 Y” 这句话的信息量往往比"我能做 A B C"更大——它直接阻止了一类错误行为。
2. 必读清单要条件化 + 带后果。 不要写"请阅读参考文档",要写"做 X 之前必读 Y,不读会导致 Z 错误"。前者是礼貌用语,后者是可判定规则。
3. 把 gotcha 当一等内容写。 +get-my-tasks 不带 --complete 会混入已完成任务、code == 0 判定会误判成功、--start 不认自然语言日期——这些都是跑过才知道的知识,也是 Skill 相比 API 文档唯一的增量价值。
4. 区分「知识」与「工具」。 需要模型理解的进上下文,需要确定性执行的进脚本。官方把 108KB 的排版 lint 脚本排除出 embed 而随发行版下发,就是这个分界。
5. 预判 harness 的信息可见性。 “不要在同一轮里展示 URL 后立刻阻塞轮询"这条,只有真正在多种 Agent 运行时里跑过才写得出来。任何涉及"等用户在别处操作"的流程,都要考虑分轮交还控制权。
6. 给 Agent 留一条自查后路。 cliHelp 字段是个小设计,但它承认了一个事实:文档一定会有和现实不符的时候,那就至少让 Agent 知道去哪核对。
本文所有"上游真实情况"的判定均来自 larksuite/cli main 分支源码,核对时点为 2026-08-01。上游持续迭代,具体命令与实现可能变化——这本身也是本文核心论点的一部分。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付