cli

package
v0.34.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 4, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package cli 提供框架无关、可单测的 CLI 子命令分发原语。

设计目标是「命令逻辑能被单元测试直接调用」:分发入口是 App.Run(ctx, args, stdout, stderr) int —— 参数与输出流全部由调用方传入。 本包既不读取进程参数,也不终止进程(不读 os.Args、不调 os.Exit), 因此测试无需起子进程、无需捕获 stdout,命令体可以像普通函数一样断言。

组成:

  • Command:单个子命令(名称 + 一行摘要 + 别名 + 执行体)
  • App:子命令集合的分发器,负责查找子命令、生成用法文本、映射退出码
  • NewFlagSet / ParseFlags:标准库 flag 的薄封装,把解析错误与用法 重定向到调用方给的 stderr(标准库默认写 os.Stderr,会破坏「输出流 由调用方给」的约定)
  • Table:列宽自适应的文本表格(中文与 emoji 按两列算宽度,变体选择符按序列算: ⚠️ = U+26A0 + U+FE0F 是两列,⚠ 单写是一列)
  • Report:自检报告(doctor 式清单)的渲染原语

退出码约定写死在包里,调用方不要另立一套:

ExitOK    = 0  成功
ExitError = 1  运行失败
ExitUsage = 2  用法错误(缺少子命令、未知子命令、参数不合法)

未知子命令与参数不合法同归 ExitUsage(2);子命令返回什么,整体就返回 什么(0→0、1→1、2→2)。

三件不必各写一遍的事:

  • 内建帮助:--help / -h / help 打印用法到 stdout,退出码 0。问帮助不是 错误,所以不是 2。宿主自己声明了名为 "help" 的子命令时它照常工作。
  • 内建版本:App.Version 非空时,--version / version 打印版本到 stdout。
  • 默认子命令:App.Default 非空时,无参等于执行它(以 stdio 提供服务的 进程通常没人给它传子命令名,而「没给子命令」在那种场景里等于 「按默认方式跑」)。

用法(main 里只做三件事:拼 args、给流、拿退出码):

func main() {
	app := &cli.App{
		Name:    "mytool",
		Default: "serve",
		Version: "1.0.0",
		Commands: []cli.Command{
			{Name: "build", Summary: "构建产物", Run: runBuild},
			{Name: "serve", Summary: "启动服务", Run: runServe},
			{Name: "doctor", Summary: "自检", Aliases: []string{"--doctor"}, Run: runDoctor},
		},
	}
	os.Exit(app.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
}

命令体(可直接被单测调用,无需起子进程):

func runBuild(ctx context.Context, args []string, stdout, stderr io.Writer) int {
	fs := cli.NewFlagSet("build", stderr)
	release := fs.Bool("release", false, "发布模式")
	rest, code := cli.ParseFlags(fs, args)
	if code != cli.ExitOK {
		return code
	}
	_ = release
	_ = rest
	return cli.ExitOK
}

用法文本由 Command.Summary 自动生成,调用方不需要手写:

  • App.Usage(w) 单独打印(宿主想接自己的 --help 分支时写到 stdout)
  • App.Run 在「缺少子命令」或「未知子命令」时把用法写到 stderr 并返回 ExitUsage(错误路径的诊断信息一律走 stderr,stdout 只留给命令体的 正常输出,便于管道)

Index

Constants

View Source
const (
	// ExitOK 成功。
	ExitOK = 0
	// ExitError 运行失败:子命令执行出错。
	ExitError = 1
	// ExitUsage 用法错误:缺少子命令、未知子命令、参数不合法。
	ExitUsage = 2
)

退出码约定(写死在包里,宿主与子命令共用同一套语义)。

View Source
const (
	// StatusOK 通过。
	StatusOK = "ok"
	// StatusWarn 有问题但还能用——需要人看一眼,不算坏。
	StatusWarn = "warn"
	// StatusFail 坏了。
	StatusFail = "fail"
)

检查项的状态。取值固定,Report 之外的任何判断都按这三个读。

Variables

This section is empty.

Functions

func NewFlagSet

func NewFlagSet(name string, stderr io.Writer) *flag.FlagSet

NewFlagSet 创建绑定到 stderr 的标准库 FlagSet。

两个关键点:

  • flag.ContinueOnError:解析出错不终止进程,而是返回 error,交由调用方 映射成退出码(ExitUsage)——与本包「不调 os.Exit」的约定一致;
  • fs.SetOutput(stderr):把标准库默认写到 os.Stderr 的错误与用法重定向 到调用方给的 stderr,遵守「输出流由调用方给」的约定。

典型用法:

fs := cli.NewFlagSet("build", stderr)
release := fs.Bool("release", false, "发布模式")
rest, code := cli.ParseFlags(fs, args)
if code != cli.ExitOK {
	return code
}

注意:FlagSet 不支持并发 Parse;每个子命令自建一个即可。

func ParseFlags

func ParseFlags(fs *flag.FlagSet, args []string) ([]string, int)

ParseFlags 用 fs 解析 args,返回剩余位置参数与退出码。

  • 解析成功:返回 (位置参数, ExitOK)
  • -h/--help:标准库已把用法写到 fs 的输出流(调用方给的 stderr), 返回 (位置参数, ExitOK)——帮助请求不算错误
  • 解析失败(未定义的 flag、取值类型不匹配等):标准库已把错误信息与 用法写到 fs 的输出流,返回 (nil, ExitUsage)

fs 为 nil 时返回 (nil, ExitUsage)。

Types

type App

type App struct {
	// Name 程序名,用于用法文本与错误信息;为空时退化为 "app"。
	Name string
	// Commands 子命令列表。用法文本按声明顺序展示(作者自己决定顺序),
	// 同名子命令以先声明的为准。
	Commands []Command
	// Default 无参时执行的子命令名。
	//
	// 为空则无参是用法错误(ExitUsage)——这是本包的默认行为,保持不变。
	// 填上之后,`prog` 与 `prog <Default>` 等价。
	//
	// 它解决的是「这个程序不接参数也能干活」的场景:以 stdio 提供服务的
	// 进程通常是被框架拉起来的,没人给它传子命令名,而「没给子命令」在
	// 别的程序里等于用法错误,在这里等于「按默认方式跑」。
	//
	// Default 指向不存在的子命令是宿主装配错误(ExitError),不是用法
	// 错误。
	Default string
	// Version 版本串。填上之后提供内建的 version 子命令与 --version 标志。
	//
	// 为空则不提供:此时 `version` / `--version` 是未知子命令(ExitUsage)。
	// 版本串由宿主自己给(构建期注入),本包不读环境变量也不猜。
	Version string
}

App CLI 应用:子命令集合的分发器。

App 本身不含可变状态,零值之外的结构体在 Run 之后仍可复用;并发调用 Run 是安全的(只读 Commands)。

func (*App) Run

func (a *App) Run(ctx context.Context, args []string, stdout, stderr io.Writer) int

Run 分发 args[0] 对应的子命令,返回其退出码。

行为约定:

  • args 为空(未给子命令):有 Default 则执行默认子命令;否则往 stderr 打印提示与用法,返回 ExitUsage(2)
  • 未知子命令:往 stderr 打印错误与用法,返回 ExitUsage(2)
  • 子命令名匹配:调用该子命令的 Run,args 去掉子命令名后透传,返回值 原样返回(0→0、1→1、2→2)
  • 命令表里有空名子命令(宿主装配错误):往 stderr 打印错误,返回 ExitError(1)——不是用户输入问题,不落到 2
  • `--help` / `-h` / `help`:把用法写到 stdout,返回 ExitOK(0)。 问帮助不是错误,所以不是 2
  • `--version` / `version`(且 App.Version 非空):把版本写到 stdout, 返回 ExitOK(0)

匹配顺序:子命令名 → 别名 → 内建帮助 → 内建版本。名字排在别名前面, 所以宿主自己声明了名为 "help" 的子命令时,它仍然照常工作。

本方法不读进程参数、不终止进程:args 与两个输出流全部由调用方给定, 因此可被单元测试直接调用。stdout/stderr 传 nil 时按丢弃处理,不会 panic。

func (*App) Usage

func (a *App) Usage(w io.Writer)

Usage 把用法文本写到 w。

用法文本包含程序名、各子命令名与其 Summary、以及退出码约定; Summary 为空的子命令只列名字。子命令按声明顺序展示。

默认子命令标注「(默认)」,带别名的标注「(别名:…)」。标注附在摘要 之后——名称列只按 ASCII 名字算宽度,标注不参与列宽计算,否则中文摘要 会把整列的对齐挤歪。

宿主想接自己的 --help 分支时,直接 Usage(os.Stdout) 即可; App.Run 内部的错误路径写的是 stderr。

type Command

type Command struct {
	// Name 子命令名,对应 args[0];不可为空(空名视为宿主装配错误)。
	Name string
	// Summary 一行摘要,用于自动生成用法文本;可为空(此时只列名字)。
	Summary string
	// Aliases 子命令的别名。
	//
	// 别名与名字同等对待:先在所有子命令里精确匹配名字,匹配不到再在
	// 所有子命令里匹配别名。它解决的是「同一个动作有两种叫法」这件事——
	// 比如历史上以 `--doctor` 调用的子命令,改成子命令形式后仍要认旧写法。
	//
	// 别名可以带前导连字符("--doctor"),也可以不带("doc")。为空的子
	// 命令没有别名。
	Aliases []string
	// Run 执行体。args 是去掉子命令名后的剩余参数,stdout/stderr 由宿主
	// 传入。返回值即退出码,原样透传给 App.Run 的调用方。
	Run func(ctx context.Context, args []string, stdout, stderr io.Writer) int
}

Command 单个 CLI 子命令。

type Report added in v0.31.0

type Report struct {
	// contains filtered or unexported fields
}

Report 是自检报告。

它只负责渲染,不负责判断:哪些检查项算失败由调用方通过 Failures()/ Warnings() 自己看,退出码也由调用方定。报告是给人看的,退出码是给脚本 看的,两者的口径不必相同。

func NewReport added in v0.31.0

func NewReport(title string) *Report

NewReport 建一份报告。title 是第一行(通常是「<程序名> doctor」)。

func (*Report) Add added in v0.31.0

func (r *Report) Add(status, name, detail string) *Report

Add 追加一个检查项。

func (*Report) AddConclusion added in v0.31.0

func (r *Report) AddConclusion(line string) *Report

AddConclusion 追加一行结论(写在检查项之后、结果行之前)。

它承载的是「检查项合起来说明什么」——判定、建议、范围一类的文字。

func (*Report) AddFix added in v0.31.0

func (r *Report) AddFix(status, name, detail, fix string) *Report

AddFix 追加一个检查项,并带一条修复建议。

修复建议单独一行,缩进到名称列的起点——它讲的是这一项怎么修,挂在这一 项下面才读得懂。

func (*Report) Failures added in v0.31.0

func (r *Report) Failures() int

Failures 返回失败(StatusFail)的检查项个数。

func (*Report) Header added in v0.31.0

func (r *Report) Header(lines ...string) *Report

Header 追加头部信息行(键值对已由调用方格式化好)。

头部与检查项之间空一行——它们是两类东西:上面是「这是什么环境」,下面 是「查出来什么」。

func (*Report) Items added in v0.31.0

func (r *Report) Items() []ReportItem

Items 返回检查项(副本)。

func (*Report) Render added in v0.31.0

func (r *Report) Render(w io.Writer)

Render 把报告写到 w。

func (*Report) Result added in v0.31.0

func (r *Report) Result(text string) *Report

Result 设置最后一行结果,替换掉自动生成的那一行。

不调用它时,结果行按 Status() 自动生成(healthy / needs attention / failed)。调用方要自己的措辞时用它覆盖——两个程序对「什么算坏了」的 口径本就可能不同(有的把警告也算成「需要处理」,有的只算失败),措辞 跟着口径走。

func (*Report) Status added in v0.31.0

func (r *Report) Status() string

Status 返回整份报告的状态:失败压过警告,两者都没有才是 ok。

func (*Report) String added in v0.31.0

func (r *Report) String() string

String 返回报告的文本。

形状:

<标题>
  <头部行>

  [OK  ] 检查项名   详情
         fix: 怎么修

<结论行>
result: healthy

func (*Report) Warnings added in v0.31.0

func (r *Report) Warnings() int

Warnings 返回警告(StatusWarn)的检查项个数。

type ReportItem added in v0.31.0

type ReportItem struct {
	// Status 状态:StatusOK / StatusWarn / StatusFail。别的值按未知渲染。
	Status string
	// Name 检查项的名称。
	Name string
	// Detail 一句话说明这一项查出了什么。
	Detail string
	// Fix 修复建议。可为空——为空就不写这一行。
	Fix string
}

ReportItem 是一个检查项。

type Table added in v0.31.0

type Table struct {
	// contains filtered or unexported fields
}

Table 是列对齐的文本表格。

列宽自适应:每列取该列所有单元格(含表头)的最大显示宽度,列与列之间 隔两个空格。最后一列不补尾随空格。

单元格里的换行原样写出(表格不折行)——折行会让「一格一行」的读法失效, 要折行请自己把内容拆成多行。

func NewTable added in v0.31.0

func NewTable(headers ...string) *Table

NewTable 建一个带表头的表格。

表头可以不给(传空即可),此时只有数据行。

func (*Table) Append added in v0.31.0

func (t *Table) Append(cells ...string)

Append 追加一行。

单元格少于列数时补空串,多于列数时多出来的部分被丢弃——表格的形状由 表头决定,不能被一行数据撑歪。没有表头时,形状由第一行决定。

func (*Table) Render added in v0.31.0

func (t *Table) Render(w io.Writer)

Render 把表格写到 w。

表头与数据行之间不加分隔线:CLI 表格通常很短,一条横线会让人误以为 下面那段是另一个区段。

没有表头时,形状由第一行决定——有些输出(如工具目录)本来就没有列名, 硬塞一个表头反而改变了它的形状。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL