看到 build-your-own-x 仓库里列出“构建一个 CLI 工具”这个高级项目时,我的第一反应和大多数开发者一样:这算什么高级项目?CLI 不就是写个 main 函数读一下 os.Args,然后循环处理参数吗?当时我带着这种想法很快写出了第一版,结果在给别人试用的第一个下午就被连续问住了:参数格式怎么和文档不一样、配置文件没生效、Windows 下直接提示找不到命令、程序退出码永远返回 0。从那一刻起我才明白,把一个命令行工具做成一个真正能交付的项目,背后是完整的工程能力训练,而不是“会写代码”那么简单。
这个项目的价值在于:它不要求图形界面,不依赖重型框架,却能覆盖软件工程里最容易被忽略的一条完整链路——用户如何安装、命令如何发现、参数如何解析、错误如何反馈、输出如何被脚本消费、版本升级如何落地。换句话说,这是以最小成本练习“把代码变成产品”的路径之一。对于想从脚本开发跨到系统工具开发、想补上工程化短板的人来说,认真做完一个 CLI 项目,比刷一百道算法题更能建立全局观。
接下来我会结合自己用 Go 和 Rust 分别写过 CLI 工具、也用 Python 快速搭过内部脚本工具的经历,把从设计、实现、打包到排查的完整过程拆开来讲。如果你正在做类似的高级项目,可以把这篇文章当成一份可以照着操作的复盘笔记。
1. 项目先做减法:CLI 工具练的不是功能,是边界
很多人在做这个项目时,第一件事就是疯狂堆功能。今天加一个颜色输出,明天加一个配置文件模板,后天又想支持远程调用。我见过最夸张的 demo 只有一千行代码,却硬塞了十几个子命令,最后每个命令都只能处理最简单的 happy path。
做 CLI 工具和做 Web 服务最大的区别是:命令行环境下,工具通常只专注解决一个垂直问题。比如 jq 就是处理 JSON,ripgrep 就是做文本搜索,htop 就是看系统进程。它们做得非常克制,但正因为克制,用户才愿意把它留在自己的工具箱里。所以动手前最重要的事,是给项目划清能力边界。
1.1 先定义“谁在用、解决什么问题”
给自己提三个问题,然后写进项目 README 的第一段:
- 用户是谁?是开发者、运维,还是普通行政人员?这决定了参数风格和错误提示的表达方式。
- 输入是什么?是单个文件、文件夹、标准输入,还是一串配置参数?
- 成功标准是什么?比如“一分钟内完成批量文件重命名”算成功,“支持三百种格式转换”不算成功。
这三个问题的答案会直接影响架构。举个例子,如果你的工具要处理大量输入文件,那么你就得考虑标准输入管道(stdin)的用法;如果用户是 CI 里的脚本调用方,那输出必须可预测,不能带 ANSI 颜色;如果用户是普通办公人员,你反而要在交互提示上多花力气。
当年我做第一个 CLI 项目时,选了一个很朴素的场景:批量重命名图片文件,按照拍摄日期自动整理到目录里。场景足够小,用户就是自己和同事,输入是一个目录路径,成功标准是“一条命令跑完,目录结构清晰”。正是因为需求极其具体,后面所有功能取舍都有了判断依据,我不需要纠结要不要支持“远程同步”这种明显超出边界的功能。
1.2 以“第一次运行”的心态设计用户路径
CLI 的用户界面不是窗口,而是帮助信息、参数名、输出文本。用户对你工具的第一印象,往往是 tool --help 的输出。
我建议在设计阶段就把下面的命令路径走一遍:
bash复制mycli --help
mycli init
mycli run --input ./data --output ./result
mycli --version
每一条都要在文档里解释清楚。尤其是 --help 和 --version,这是所有用户都会触碰的入口。很多新手写的 CLI 根本不处理这两个参数,一输入就报“unknown flag”,这种体验是非常劝退的。
在完成第一版前,还应该想清楚命令的单数复数形式。比如项目管理工具是用 task add 还是 tasks add?子命令的命名尽量统一为动词开头,如 add、remove、list、run、init,避免一个工具里既有名词又有动词。看起来是小事,但命令行工具的使用习惯高度依赖肌肉记忆,拼写不统一会让工具显得不专业。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:不同语言写 CLI,体感差距比想象中大
选编程语言是这个项目最纠结的一步。我在不同项目里分别用 Go、Rust、Python 写过 CLI,最终感受是:它们在能力上没有本质差别,但在“编译体积”“分发难度”“依赖管理”“生态成熟度”四个维度上差别很大。
2.1 主流语言横向对比
我先列一张基于个人实践的对比表,方便你根据自己情况做选择:
| 语言 | 编译产物 | 运行时依赖 | 参数解析生态 | 上手难度 | 适合场景 |
|---|---|---|---|---|---|
| Go | 单个静态二进制 | 无 | cobra/urfave-cli 很成熟 | 低 | 跨平台工具、需要快速分发 |
| Rust | 单个静态二进制 | 无 | clap 功能极强 | 中高 | 追求性能、类型安全、长期维护 |
| Python | 源文件或 zipapp | 需 Python 环境 | argparse/click/typer | 低 | 内部脚本、快速迭代 |
| Node.js | 源文件或 pkg 打包 | 需 Node 环境 | commander/yargs | 中 | 前端生态内工具 |
如果你要做的是“给别人分发、长期使用”的工具,我强烈建议优先考虑 Go 或 Rust。原因很简单:它们能编译出单文件二进制,放到目标机器上就能跑,不需要用户先装 Python 或 Node。我在公司内部推广 Python 写的工具时,至少遇到三次“为什么我这台机器跑不起来”的咨询,最后查出来都是 Python 版本不一致或缺少依赖;而用 Go 编译出来的二进制,丢过去就能跑,这类问题直接消失。
Go 对初学者尤其友好。它的并发模型简洁,标准库里的 flag 不够用就直接上 spf13/cobra,社区资料非常多。如果追求更极致的性能和更严格的类型安全,Rust 是更好的选择,配合 clap 的 derive 宏,定义参数结构体比手写解析逻辑省力得多。但要付出编译心智成本,特别是借用检查器,对新手并不友好,所以不要只看网上“Rust 写 CLI 很爽”的帖子就盲目入坑。
如果你只是做内部自动化脚本,Python 的 argparse 或 click 就足够了。重要的是不要用 sys.argv 裸解析,除非你永远只接受一个参数。一旦参数超过三个,手工解析的代码会迅速变成一团乱麻,连你自己过两周都看不懂。
2.2 参数解析库不是越强越好
参数解析库决定了你代码的“骨架感”。我见过有人用 Go 的 flag 写了一个工具,因为不支持子命令,最后只能靠 flag.Args()[0] 人工切分,代码越写越别扭。选库的时候要确认它支持三个能力:
- 子命令嵌套,至少两级。
--flag value和--flag=value两种写法都支持。- 自动生成 help 文本,并带有默认值显示。
具体到语言层面,我的推荐是:
- Go:
spf13/cobra,最主流,很多知名项目都在用。 - Rust:
clap开启derive特性,定义简单直观。 - Python:
click比argparse舒服很多,装饰器写法清晰,还能自动处理环境变量。 - Node.js:
commander,API 设计很简洁。
这里有个很容易被忽略的细节:参数解析库的报错信息是否友好。默认生成的错误一般都能用,但有些场景,比如用户输入了一个不存在的子命令,库给出的提示可能是一行冷冰冰的 unknown command "foo" for "mycli"。更好的交互是紧接着给出 Did you mean this? 的建议列表。在 cobra 里,可以开启 suggestions;clap 也支持类似能力。不要觉得这种细节无所谓,命令行工具的用户没有鼠标可点,报错信息就是他唯一的导航。
2.3 参数设计的三层结构
我在设计参数时会把它们分成三层,避免所有参数都堆在同一个层级里:
code复制工具名 子命令 [必备参数] [可选参数]
- 第一个参数是子命令,承担动作语义:
run、init、clean。 - 第二个参数是位置参数,通常是路径、文件对象或资源名称。
- 第三个参数是 flags,用来修改动作的行为:
--config、--verbose、--output。
一个反例是某些命令把路径也做成 flag:mycli --file path --mode run --output out,表面上一视同仁,实际上违背直觉。用户习惯是“动词 + 对象 + 修饰”,也就是 mycli run path --output out。参数层级一旦跟用户的思维模式错位,工具再强大都会被嫌弃。
3. 核心实现:从一个能跑的版本开始
讲完设计,进入实操。我以最简单的 Python 工具为例展示骨架,因为在代码可读性上最适合做教学;但工程步骤通用,换到 Go 或 Rust 只是 API 不同。
3.1 项目骨架怎么搭
一个高完成度的 CLI 项目,目录结构通常长这样:
text复制mycli/
├── README.md
├── pyproject.toml
├── src/
│ └── mycli/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── config.py
│ ├── commands/
│ │ ├── __init__.py
│ │ ├── init.py
│ │ └── run.py
│ └── utils/
│ ├── __init__.py
│ ├── output.py
│ └── errors.py
├── tests/
│ ├── test_cli.py
│ └── test_config.py
└── docs/
└── usage.md
注意 __main__.py 的存在让用户可以用 python -m mycli 来运行,这在开发阶段非常有用。如果项目是单文件,我建议至少保留 cli.py 和 config.py 两个模块,因为参数解析和配置加载永远是两件事,强行写在一起,后面改起来会很难受。
3.2 用 click 快速实现一个可扩展的入口
下面是使用 click 实现的极简入口代码:
python复制import click
@click.group()
def cli():
"""mycli - a sample command line tool."""
@cli.command()
@click.option("--force", is_flag=True, help="Force initialization.")
def init(force):
"""Initialize a workspace."""
click.echo("initialized with force=%s" % force)
@cli.command()
@click.argument("input_path")
@click.option("--output", "-o", default="result.txt", show_default=True)
@click.option("--verbose", "-v", is_flag=True)
def run(input_path, output, verbose):
"""Run the main task on INPUT_PATH."""
if verbose:
click.echo(f"process {input_path} -> {output}")
# 主逻辑...
click.echo("done")
if __name__ == "__main__":
cli()
看到没有?参数的声明和业务逻辑完全分离,写命令的体验更接近“描述接口”,而不是手工解析字符串。click 还会自动生成 help 信息,这比用 argparse 少写不少代码。核心逻辑一旦出错,click 会捕获异常并把堆栈返回给 debug 模式;平时则显示用户友好的错误文本,这点对 CLI 的用户体验非常重要。
3.3 核心逻辑不要写在“命令回调函数”里
有一个会被很多人忽略的架构问题:把业务逻辑全部写在命令回调函数里,看起来方便,但测试和复用都很困难。更合理的做法是,命令层只负责参数提取和异常转换,真正的业务逻辑放到独立的 service 或 core 模块中。
举例来说,mycli run input.txt --output out.txt 的回调函数里应该只做三件事:
- 参数校验和路径规范化
- 调用
core.process_file(input_path, output_path, verbose=verbose) - 捕获核心模块抛出的异常,输出友好的错误信息并返回退出码
核心模块本身不依赖 click、argparse,甚至不依赖命令行参数。这样就可以很容易对核心模块做单元测试,也可以通过其他入口调用,比如一个未来会出的 Web 界面。
3.4 输出规范:stdout 和 stderr 必须分离
CLI 开发中一个极重要却经常被忽视的规则:正常输出走 stdout,错误和日志走 stderr。这不仅是一条约定,更是脚本是否能正确协作的基础。假设你的程序把错误日志也打到 stdout,那么在 shell 里执行 mycli run --config bad.yaml | grep SUCCESS,错误信息就会混进管道,导致下游拿到脏数据。
用 Python 的 print 会默认输出到 stdout,所以打印日志和错误时,要么显式指定 file=sys.stderr,要么用 click.echo(..., err=True)。在 Go 里对应 fmt.Fprintln(os.Stderr, ...);在 Rust 里是 eprintln!。
输出格式如果不是给人看的,我建议增加一个 --format json 选项,让工具输出的内容可以被机器可靠解析。JSON 输出方式要求保证字段稳定,不要随便改 key 名。如果字段命名改了,相当于 API 破环,所有调用方都会受影响,所以一开始就要定好结构,比如统一为:
json复制{
"status": "ok",
"result": {},
"duration_ms": 12
}
错误时输出为:
json复制{
"status": "error",
"error": {
"code": "CONFIG_NOT_FOUND",
"message": "configuration file ./config.yaml does not exist"
}
}
3.5 配置文件加载优先级
任何有点实际用途的 CLI 都会遇到配置问题。写死参数不可取,每次传 --config 又太累。常见的做法是提供一个默认的配置文件格式,并按固定优先级加载。
我采用过的优先级策略是:
- 命令行参数(最高)
- 环境变量
- 指定位置的配置文件(如
./mycli.yaml或~/.config/mycli/config.yaml) - 内置默认值(最低)
不要反转优先级。命令行参数必须是最大外力,否则用户没法在 CI 里临时覆盖配置文件里的取值。实现时不要在主流程里到处 os.getenv,而应该在启动阶段把环境变量统一读入一个 Settings 对象,之后所有业务逻辑只依赖这个对象。
这段配置加载代码是一个典型示例:
python复制import os
from dataclasses import dataclass
@dataclass
class Settings:
input_dir: str = "."
output_dir: str = "out"
verbose: bool = False
threads: int = 4
def load_settings(cli_args, config_dict=None, env=os.environ):
s = Settings()
# 配置文件
if config_dict:
s.input_dir = config_dict.get("input_dir", s.input_dir)
s.output_dir = config_dict.get("output_dir", s.output_dir)
s.threads = config_dict.get("threads", s.threads)
# 环境变量覆盖
s.input_dir = env.get("MYCLI_INPUT_DIR", s.input_dir)
s.output_dir = env.get("MYCLI_OUTPUT_DIR", s.output_dir)
# 命令行参数最高
if cli_args.get("input_dir"):
s.input_dir = cli_args["input_dir"]
return s
4. 让工具被“找得到”:安装、PATH 与路径排查
一个 CLI 写得再好,如果用户装了却找不到命令,前面所有工作都白费。我在实际项目中遇到过大量“明明已经安装了,为什么系统提示找不到”的问题。多数情况下不是代码 bug,而是二进制没有被放到 PATH 中,或者安装包路径结构有误。
4.1 为什么出现 “unable to locate the xxx cli binary”
最近我注意到一个非常流行的报错模式:很多桌面端应用在启动时会尝试调用内置的 CLI binary,但屏幕上显示类似 unable to locate the xxx cli binary、set xxx_cli_path or ensure the Electron resources include bin/xxx 的错误。这不是单纯的 PATH 问题,而是主程序在初始化阶段用固定路径或系统 PATH 去探测可执行文件,但探测失败了。
这类问题有几种常见原因:
- 用户安装的桌面版本和命令行版本不是一起安装的,CLI 目录没有单独加进 PATH。
- 安装过程中杀毒软件拦截了 bin 释放,导致资源目录里缺少可执行文件。
- 主程序使用
process.execPath同级目录或 Electronresources目录计算路径,而自定义安装目录改变了这个相对结构。 - 系统环境变量未刷新,用户需要重启终端或桌面应用。
排查思路其实很简单:先看程序期望的路径是什么,再看实际文件在哪里。如果它说“在 Electron resources 中找 bin/xxx”,你先手动检查一下这个文件是否存在;存在但没有权限,就补执行权限;不存在,就检查安装包版本或手动指定路径。很多产品会在设置项里提供一个 CLI Path 手动配置入口,你可以直接把实际路径填进去,绕开自动探测。
4.2 安装到系统 PATH 的两种推荐姿势
自己做 CLI 并分发给别人时,除了提供源码运行,还要考虑安装脚本。最常见的是两种方式:
- 全局安装到
/usr/local/bin,一般需要管理员权限。 - 用户态安装到
~/.local/bin,不需要管理员权限,但需要保证~/.local/bin在 PATH 中。
我个人更推荐第二种,尤其是在公司环境下,不是所有开发者都有 sudo 权限。因此安装脚本可以做成这样:
bash复制#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="${HOME}/.local/bin"
mkdir -p "${INSTALL_DIR}"
cp ./mycli "${INSTALL_DIR}/mycli"
chmod +x "${INSTALL_DIR}/mycli"
if ! echo "$PATH" | grep -q "${INSTALL_DIR}"; then
echo "Please add ${INSTALL_DIR} to your PATH:"
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc"
fi
注意脚本执行完后要验证安装。给用户一个明确的下一步动作,而不是静默退出。验证命令最好固定写进 README:
bash复制which mycli
mycli --version
如果 which 找不到,但你明明安装到了 ~/.local/bin,就说明 shell 的 PATH 里没有这个目录。这种情况下不要急着怀疑安装脚本,而是先执行 echo $PATH。
4.3 版本升级时的路径陷阱
CLI 工具的升级也经常踩坑。很多安装脚本只是简单覆盖二进制,但如果旧版本的二进制被某个进程占用(Windows 上尤其常见),覆盖会失败;在 Linux 上则可能出现下载了一个新版本,但 shell 的 hash 缓存还指向旧的二进制路径,于是用户执行 mycli --version 后看到的仍是旧版本号。
解决办法有两个:一是安装脚本固定改名成带版本号的路径,再用软链接指到无版本号路径;二是提醒用户执行 hash -r 刷新 shell hash 缓存。在实现层面,每次启动时打印版本号或者提供 mycli version 子命令,并且建议用户升级后用 --version 和文档对照,能节省大量排查时间。
5. 工程化加固:从“能跑”到“能长期维护”
功能完成后,如果只是个人使用,你已经可以收工了。但如果这个项目想放进简历、开源给别人,或者接受社区贡献,工程化层面的加固必不可少。
5.1 自动补全和帮助文档
当你的子命令超过三个、flags 超过十个的时候,记忆负担会明显上升。建议接入 shell 自动补全。在 Go 的 cobra 和 Rust 的 clap 中,生成补全脚本都是内置能力;Python click 里也有类似接口。补全脚本生成好后,需要让用户把它加入 shell 配置,或者你的安装脚本自动生成。
帮助信息不是越详细越好,而是应该有层级。mycli --help 应该只展示高层说明和常用子命令,让用户一眼看明白这个工具是干什么的;mycli run --help 再详细展示本子命令参数。写参数说明时不要写“input file”,要写“input file path, support glob pattern”,说明越具体,问问题的用户越少。
5.2 测试:不能只测“正常情况”
CLI 的测试至少要有两层。
第一层是核心逻辑单元测试,不启动真实命令,直接调用处理函数。第二层是端到端集成测试,用子进程运行编译出的二进制,传入参数,断言退出码与 stdout/stderr 内容。
集成测试中最典型的场景是“golden file”测试:准备一组输入文件和对应的预期输出文件,跑命令后和预期对比。这个模式非常适合重命名、生成报告这类需求。遇到非确定输出时,可以用正则或 JSONSchema 做比较规则,而不是无脑全量 diff。
测试用例里至少要包含下面几类输入:
- 空输入,如空目录、空文件。
- 非法参数,如缺少必选参数。
- 路径不存在。
- 权限不足。
- 配置文件格式错误。
- 重复执行,验证幂等性。
有一个心得:比起写十几个花哨的单测,把“真实命令失败时退出码是否正确”这一条覆盖到更重要。因为用户集成到 CI 脚本时,最关注的就是退出码是否可靠。
5.3 退出码必须语义化
Linux 和各类 CI 环境都会读取进程退出码。0 表示成功,非 0 表示失败。但非 0 到底是多少,很多工具处于“全部返回 1”的状态,这对脚本来说不够友好。
尝试给错误码分级:
| 退出码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 运行时未知错误 |
| 2 | 参数错误(flag misuse) |
| 3 | 配置文件加载失败 |
| 4 | 输入文件不存在或不可读 |
| 5 | 权限不足 |
| 6 | 内部逻辑错误 / 未处理异常 |
在代码中定义一个错误码常量模块,而不是在函数里随手 os.exit(1)。命令回调函数只负责转换异常类型并设置退出码,这样做的好处是,调用方可以使用 if [ $? -eq 2 ]; then 这类代码区分错误阶段,而不是把所有非 0 当同一个错误处理。
5.4 超时、信号与中断处理
CLI 工具有一个隐藏特质:它通常运行在“可中断”场景。用户在终端里随时可能按下 Ctrl+C。如果你的工具正在写文件或下载数据,不做清理直接退出,就可能留下半个临时文件。
所以在设计阶段就要给耗时长、涉及资源写入的命令加上信号处理。用 Go 写可以用 signal.NotifyContext,在 Python 里可以给 SIGINT 一个 handler,确保临时文件被删除、网络连接被关闭。另外,批处理任务还应该支持 --dry-run,让用户先看一遍“将要做什么”,再实际执行。这个参数在文件删除、批量重命名工具里几乎是救命的。
6. 常见问题速查:这是我踩过最深的坑
下面这些问题既是我在开发 CLI 工具时真实遇到的,也是很多参与同类高级项目的人反复踩的坑。整理成一张速查表,建议贴到自己项目 README 的 Troubleshooting 章节里。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
终端提示 command not found |
安装目录不在 PATH 中 | 检查 PATH,添加 ~/.local/bin 或 /usr/local/bin |
| 更新了二进制,版本号没变 | shell hash 缓存 | 执行 hash -r,或重启终端 |
| 桌面应用提示 unable to locate xxx cli binary | CLI 文件不在资源目录或 PATH 探测失败 | 检查安装目录,手动设置 CLI Path,确认执行权限 |
| Windows 下双击运行闪退 | 控制台程序被当 GUI 启动 | 在系统终端(cmd/PowerShell)中执行 |
| 中文路径处理异常 | 文件编码或路径转义问题 | 使用标准库的路径 API,避免手工拼接字符串 |
| 管道输出带颜色,CI 日志乱码 | ANSI 颜色没有被禁用 | 检测 NO_COLOR 环境变量或非 TTY 时禁用 |
| stdout 混入日志,JSON 解析失败 | 日志输出到了 stdout | 日志统一走 stderr,stdout 只输出正式结果 |
| 配置文件写错却没人发现 | 校验不够严格 | 增加 schema 校验,错误时给出具体字段位置 |
| 删除操作没有后悔药 | 缺少回收机制 | 提供 --dry-run,删除前自动备份到隐藏目录 |
我再分享一个容易忽略的细节:不管你的 CLI 是 Go、Rust 还是 Python 写的,都要谨慎处理“当前目录不是项目根目录”的情况。很多工具假设用户就在项目根目录里执行,结果当用户从别的目录运行时,用了相对路径加载配置,导致找不到文件。正确的做法是,先通过命令行参数确定路径,如果没有给定路径,再用 os.Getwd() 获取当前目录,但不要默认用户一定在某个固定目录。路径相关错误提示里,一定要把“当前实际的工作目录”和“尝试查找的路径”都打出来,否则用户只能靠猜。
在做完多个 CLI 项目后,我个人最深的体会是:CLI 工具的能力边界不是靠堆特性来证明的,而是靠“用户每一次敲击键盘后的可预期性”来证明。安装完能不能直接跑、参数错了能不能得到可读提示、输出能不能被脚本消费、错误退出码能不能被上游捕捉,这些细节比内部架构设计得更精巧更能决定工具的生命力。如果你也在做这个高级项目,建议在代码写完第一个可运行版本后,立刻邀请旁边的人来试用一次,什么都不解释,只看他能不能在没有 README 的情况下顺利完成三条基础命令。当时我这样试过之后,才发现原来自己写的“帮助信息”里到处都是只有自己能看懂的缩写。
