VINO/WANG返回博客 ←

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 服务承载打包的产物。整套闭环核心就三处文件:

  1. Makefile 承接打包和发布流程
  2. scripts/install.shgen-manifest.shinternal/update/ 判断如何更新
  3. 通过 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 上面的内容:

  1. 6 个二进制(交叉编译产物)
  2. install.sh / install.ps1 ——首次安装入口,OSS_BASE 在发布期被 sed 替换进去
  3. SHA256SUMS —— 所有二进制的校验和
  4. 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 格式,命名为Envelopeyuan-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 那最好了。

参考资料