BLOG / POST
如何构建 Agent Friendly CLI
结合最近开发的 yuan CLI ,分享下CLI从零到上线的全流程
- agent
- cli
- skill
最近我们公司在做 AI 行业化转型,针对我们资产管理「圆资产」应用,要打通与 Agent 之间的连接,能够让用户通过 AI 直接操作公司的资产。为此我们开放了一些操作类以及数据查询的接口出来。说到打通与 AI 的连接,首先想到的是开发一套 SKILL,在 SKILL 里面写清楚流程,结合开放接口写下脚本,这样基本上就能完成了。
我们第一版也是这么做的,发布了第一版之后就先在我们内部测试起来。整体跑下来看流程没问题,接口也都正常调用。但很快就发现问题,由于操作流程调整,对应到要修改脚本,但对于资产管理员同学,即使有 AI 能帮助他们修改但还是会出错,AI 在不断尝试的过程中消耗大量的 Token 造成浪费。于是我着手开始封装 CLI,灵感来自于飞书的 lark-cli 和 Github 的 gh。
先搭建框架
一个只在本地能跑的 CLI 谈不上“工具”,只是个脚本。真正成为“工具”的标志,就是 打包 → 发布 → 更新 这条闭环能自洽运转。
yuan-cli 采用 Go 语言编写,通过阿里云 OSS 服务承载打包的产物。整套闭环核心就三处文件:
Makefile承接打包和发布流程scripts/install.sh、gen-manifest.sh和internal/update/判断如何更新- 通过
ldflags把版本元数据注入二进制
打包:把源码变成“知道自己是哪个版本”的二进制
关键设计是 ldflags 注入元数据——这是整个闭环的身份系统。
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)
真正的值在 Makefile 用 -ldflags -X 编译期烧进去:
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X $(MODULE)/internal/version.Version=$(VERSION) \
-X $(MODULE)/internal/version.Commit=$(COMMIT) \
-X $(MODULE)/internal/version.Date=$(DATE) \
-X $(MODULE)/internal/update.Base=$(OSS_BASE)
四个注入点干了三件事:
| 注入到 | 作用 |
|---|---|
version.Version / Commit / Date |
yuan --version 输出有内容,让二进制“知道自己是谁” |
update.Base(OSS URL) |
升级命令知道去哪拉 manifest,这是升级闭环的入口 |
默认值 dev / none / unknown |
go run 没烧 ldflags 时也能跑,但 update 走 manifestURL() 会返回 config error——开发版优雅降级 |
然后 Makefile 做交叉编译,6 个目标(darwin/linux × amd64/arm64,windows × amd64/arm64),CGO_ENABLED=0 保证纯静态可移植。
发布:产出一份“机器可读的版本清单”
产出的 manifest.json 作为契约文件,打包产物、install 脚本、upgrade 命令三方都依赖它,最终发布到 OSS 上面的内容:
- 6 个二进制(交叉编译产物)
install.sh/install.ps1——首次安装入口,OSS_BASE在发布期被sed替换进去SHA256SUMS—— 所有二进制的校验和manifest.json—— 升级用的版本清单
bin/
├── dist/ ← make release 产物(发布目录)
│ ├── SHA256SUMS ← 6 个二进制的 sha256 校验和(给 install.sh 用)
│ ├── install.ps1 ← Windows 首次安装脚本(OSS_BASE 已替换)
│ ├── install.sh ← macOS/Linux 首次安装脚本(OSS_BASE 已替换)
│ ├── manifest.json ← 版本清单契约(给 yuan upgrade 用)
│ ├── yuan-darwin-amd64 ← macOS Intel
│ ├── yuan-darwin-arm64 ← macOS Apple Silicon
│ ├── yuan-linux-amd64 ← Linux x86_64
│ ├── yuan-linux-arm64 ← Linux aarch64
│ ├── yuan-windows-amd64.exe ← Windows x86_64
│ └── yuan-windows-arm64.exe ← Windows ARM
manifest.json 的内容如下
{
"latest": "v0.1.8",
"versions": [{
"version": "v0.1.8",
"assets": [
{"os":"darwin","arch":"arm64","url":"https://...","sha256":"..."}
]
}]
}
这里面的 latest 字段是发布动作写入的“最新版本锚点”。升级逻辑只读这一个字段就能判断有没有新版。
更新:拉 manifest → 比版本 → 下载 → 校验 → 原子替换
更新逻辑在 internal/update/ 里里面,整个流程是如下这样一条 pipeline,并且添加了 --check flag,check 环节只走前两步就返回,用来判断有没有新版本要更新。
FetchManifest → CompareVersion → SelectAsset → DownloadAndReplace
到了下载环节流程如下
temp 文件创建在同目录 → 流式下载 → 校验 sha256 → chmod 0755 → 原子替换
三环怎么扣成闭环
ldflags 烧 update.Base
┌────────────────────┐
│ │
git tag ──► make publish yuan upgrade
│ │ │
┌──── build-all upload ◄────┐ │
│ │ │ │
│ manifest.json ───► OSS ┴──► FetchManifest
│ SHA256SUMS │
│ install.sh │
│ │ │
└─ first-time user curls install.sh │
│
CompareVersion ◄────┘
│
DownloadAndReplace (sha256 校验 + 原子 rename)
约定 CLI 的 stdout
传统 CLI 的设计假设只有一个调用方——坐在终端前的人。所以默认行为优先可读性:彩色表格、分页器、确认提示、友好错误文案,这些对人类都很友好。
但在 lark-cli 里面,他们做了不同的选择,他们把CLI的默认输出改成了对 Agent 更友好的 json 格式,命名为Envelope 。yuan-cli 也采用了相同的方式。
对于成功的请求,返回如下固定的结构
{
"ok": true,
"data": [/* 业务数据 */],
"meta": {
"request_id": "req-123",
"page": 1,
"page_size": 10,
"total": 42,
"total_page": 5,
"truncated": false
}
}
对于失败的请求,stderr 上输出同样固定的结构:
{
"ok": false,
"error": {
"type": "rate_limit",
"subtype": "too_many_requests",
"code": 429,
"message": "请求过于频繁",
"hint": "稍后重试",
"retryable": true,
"request_id": "req-456"
}
}
对应的 Go 结构体定义很简单:
type successEnvelope struct {
OK bool `json:"ok"`
Data interface{} `json:"data"`
Meta Meta `json:"meta"`
}
type errorEnvelope struct {
OK bool `json:"ok"`
Error errorBody `json:"error"`
}
为什么这么设计?核心动机有三个。
第一,成功走 stdout,失败走 stderr。Agent 拿到一段输出可以直接 JSON.parse,不用先猜它是错误还是数据。再加上顶层 ok 字段做二次保险,即使有人误把错误重定向到了 stdout,也能从字段里分辨。
第二,错误字段被拆成机器可读与人类可读两类。type / subtype / code / retryable 是契约,Agent 应该只依据这些字段做分支判断;message / hint 是给人看的文案,随时可能调整,Agent 不允许依赖它们。这条规则直接写进了 SKILL,避免 Agent 抓着文案做正则匹配这种脆弱做法。
第三,对 jq 友好。CLI 内置了 --jq 参数(基于 gojq 实现),可以直接在进程内过滤 envelope,不必把整段 JSON 落盘再用 jq 二次处理:
yuan org users --page-all --jq '.data[] | {id, name}'
这套契约的另一面是稳定性承诺:envelope 的字段名和形状是稳定的,跨版本兼容;但 data 内部的业务字段则不一定。所以 SKILL 里反复强调“只 branch 在稳定字段上”。
退出码:被严重低估的契约
很多 CLI 的退出码只有 0 和 1,对人类够用,对 Agent 来说信息量严重不足。Agent 拿到非 0 退出码后,只能从 stderr 里抓文案猜原因,又退回到脆弱的文本匹配。yuan-cli 把退出码当作一等契约来设计,定义在 internal/app/root.go 里:
const (
exitOK = 0
exitAPI = 1
exitValidation = 2
exitConfig = 3 // 也涵盖认证失败
exitAuthorization = 4
exitRateLimit = 5
exitNetwork = 6
exitInternal = 7
exitConfirmation = 10
)
退出码与错误类型一一映射,定义在 internal/api/errors.go:
func (e *Error) ExitCode() int {
switch e.Type {
case TypeValidation: return 2
case TypeConfig, TypeAuthentication: return 3
case TypeAuthorization: return 4
case TypeRateLimit: return 5
case TypeNetwork: return 6
case TypeInternal: return 7
case TypeConfirmation: return 10
default: return 1
}
}
为什么这么设计?因为不同错误类型对应完全不同的恢复策略:
3(认证/配置)→ 提示用户重新登录,重试无意义4(鉴权)→ 提示用户申请权限,重试无意义5(限流)→ 可以退避重试6(网络)→ 可以立刻重试2(参数)→ Agent 应该修正请求,不要重试同样的输入10(缺少--yes)→ 提示 Agent 走确认流程
Agent 只需要读 exit code,就能决定下一步动作,不必去解析 stderr 文案。退出码 + envelope 是两道独立的信号,互相印证——exit code 告诉你大类,envelope 里的 error.type 告诉你细节。这套设计让 Agent 的错误处理从“猜”变成了“查表”。
内置 SKILL
CLI 内置了一份 SKILL,告诉 Agent 怎么用 yuan。提供两条访问路径:
yuan skills list # 列出内置的 skill
yuan skills read yuan # 把 yuan 的 SKILL.md 打到 stdout
yuan skills install # 写入默认目录 ~/.agents/skills/
yuan skills install --dir ~/.claude/skills
read 适合 Agent 在对话中临时查看,不污染文件系统;install 则把 SKILL 持久化到本地,让 Agent 在后续会话里自动加载。Claude Code、Cursor 都有约定的 skills 目录,安装脚本会针对不同宿主给提示。
关键设计是 skill 内容通过 //go:embed 编译进二进制:
//go:embed yuan/**
var FS embed.FS
这意味着 skill 文档与 CLI 永远同版本,不会出现“CLI 升级了,但 skill 还停留在旧版”的漂移问题。所以 SKILL 里关于自我更新的提醒非常简单:
## CLI updates
Run `yuan upgrade --check` periodically to check for a newer CLI version. Skill
content is embedded in the binary, so updating the CLI also updates the Skill.
不需要单独的 skill 更新命令——升级 CLI 本身就是升级 skill。
skill 内容本身也不是泛泛的说明书,而是一份执行契约。比如它强制要求所有写操作先 --dry-run,再人工确认,最后才 --yes;明确禁止 Agent 凭空猜测 ID;禁止把 AppSecret、access token 打到任何输出里。这些约束并不是写在文档里给开发者看的,而是直接告诉 Agent,让 Agent 在每一步都自我约束。
大数据如何处理
查询类命令默认分页,避免一次性把整张表拉到 Agent 上下文里。分页元数据放在 envelope 的 meta 字段,Agent 一眼能看到总量、总页数、是否被截断。
yuan org users --page 1 --page-size 10
但有时候 Agent 需要跨页聚合分析,例如“把所有耗材入库记录拉出来算个汇总”。手动循环翻页既慢又容易出错。于是 CLI 提供了 --page-all,把多页结果在进程内合并成一次 envelope 输出:
yuan org users --page-all --page-size 100 --page-limit 20
分页逻辑统一收敛在 internal/pagination/pagination.go 里:
const DefaultPageLimit = 20
func All[T any](
ctx context.Context,
startPage, pageSize, pageLimit int,
stderr io.Writer,
fetch Fetch[T],
) (Result[T], error)
--page-limit 默认 20 页,作为安全阀防止 Agent 不小心触发几万次请求;传 0 才是真正无上限。下载进度打到 stderr,最终的合并 JSON 打到 stdout,stdout 仍然是干净的 envelope。配合 --jq 可以在拉完之后立刻过滤,避免把大块原始数据塞进 Agent 上下文:
yuan org users --page-all --jq '.data[] | .id'
这条管道把“翻页 → 合并 → 过滤”压成了一条命令,Agent 不需要写循环、不需要中间文件,也就少了很多出错的机会。
Onboarding 设计
整个 onboarding 流程压成一句话发给Agent:
# 使用如下命令帮我安装好yuan cli 并按照提示设置好skill
# macos/linux
curl -fsSL https://oss.../install.sh | sh
#windows
irm https://oss.../install.ps1 | iex
安装脚本做了四件事:探测 OS 与架构、从 OSS 拉对应二进制、用 SHA256SUMS 校验、原子替换到 ~/.local/bin。
在安装脚本 install.sh 的结尾处,加入了如下提示
echo "installed $BINARY to $DEST"
echo ""
echo "tip: install the yuan Agent Skill for your AI assistant:"
echo " Claude Code: $BINARY skills install --dir ~/.claude/skills"
echo " generic: $BINARY skills install"
这段输出是专门写给 Agent 看的。Agent 执行完 install 之后会读到 stdout,自然就知道下一步应该走 skills install,再下一步是 yuan auth status 提示用户登录。整个 onboarding 路径被压缩成了“用户发一句话 → Agent curl 安装 → Agent 看到 hint → Agent 装 skill → Agent 引导登录”。
把流程暗示写进 stdout 而不是写进文档,是因为 Agent 不一定会去读 README,但一定会读上一条命令的输出。这是 Agent Friendly CLI 与传统 CLI 在细节上的根本差异——输出本身就是在引导 Agent 的下一步动作。
总结
回头看,从脚本到 Agent Friendly CLI 的转变,核心并不在于用什么语言写、怎么打包,而在于把 CLI 重新定位成Agent 友好的接口。如何让整套流程可以串联起来。
yuan-cli 的几个关键选择都围绕这条主线:envelope 输出让 stdout 可被直接 parse,退出码扩展成多类型契约让错误可被查表恢复,内置 SKILL 通过 embed 与二进制绑定保证文档不漂移,分页与 jq 内置让大数据处理不再依赖外层循环,安装脚本结尾的 hint 把 onboarding 主动推给 Agent。每一处都是为了减少 Agent 在调用过程中的试探与 token 浪费。
真正友好的 CLI 不是写得最全的,而是让 Agent 少猜、少试、少回退的那一个,如果能 one shot 那最好了。