写命令行工具这些年,我最大的感受是:越是“小”的工具,越能看出一个工程师对产品细节的较真程度。CLI-Anything 这个名字听起来有点狂,但它指向的是一个非常实际的需求——把“任何东西”都可以变成命令行工具。不管是一个内部 API、一个数据处理脚本、一套 DevOps 流程,还是一堆散落各处的自动化任务,最终都可以收敛到一个统一的、可脚本化的、对人友好的命令行入口。
这篇文章我打算把我自己在 CLI 工具设计、选型、实现和踩坑过程中积累的东西全部倒出来,尤其是那些你在官方文档里很难找到、只有在实际项目里反复挣扎才会明白的细节。无论你是想给团队搭一套内部工具链,还是想做一个开源 CLI 产品,或者只是想把日常重复劳动脚本化,这篇文章都能给你一份可以直接照着做的参考。
1. CLI-Anything 核心思路:为什么是命令行,以及它在解决什么问题
1.1 一切皆可命令行的底层逻辑
在图形界面泛滥的今天,我们为什么还要回头去死磕命令行?我自己的答案是:命令行是唯一一种同时满足“人可读、机器可读、可组合、可审计”四种特性的交互形式。
GUI 适合探索性操作,但你很难把一个 GUI 操作序列写成脚本去定时执行;API 适合机器间通信,但你要调试的时候还得借助 Postman、curl 这类工具做一层转换。CLI 恰好卡在中间:人类可以直接敲命令,脚本可以通过子进程调用,输出结果可以被重定向、被管道串联、被 CI 系统捕获。
CLI-Anything 这个概念,本质上就是把“一切皆可 CLI”当作一种设计哲学来实践。它不是指一个具体的软件,而是一种思路:当你面对任何一项底层能力——无论它是一个内部管理系统、一个数据库、一个云服务接口,还是一个本地文件处理流程——都可以问自己一个问题:如果我用命令行把它包一层,使用体验会不会变得更好?
大多数时候答案是肯定的。因为命令行强制你做三件事:定义清晰的入参、定义清晰的出参、定义可预期的行为边界。这本身就是一次很好的架构梳理。
1.2 核心应用场景与谁在需要它
我接触过的 CLI 工具需求,大概可以分成以下几类,你可以对照看看自己处于哪个阶段。
第一类:个人效率工具。最简单的场景。你每天要重复做某件事,比如批量重命名文件、拉取多台服务器日志、汇总不同目录下的数据。这类需求通常用 Shell 脚本就能解决,但当你发现自己的 Shell 脚本越来越长、参数越来越复杂、自己都记不住的时候,就是时候升级成一个真正的 CLI 工具了。
第二类:团队内部工具。这是 CLI 工具最常见的主场。团队内部的构建发布流程、数据迁移任务、项目管理操作,如果都靠口头传、文档抄,效率极低且容易出错。把这些操作封装成统一 CLI 后,新人上手成本直线下降——不用再读一本 30 页的操作手册,敲一行命令就能完成过去 10 步手工操作。
第三类:商业产品形态。越来越多开发者工具选择以 CLI 作为主要产品形态,比如各种云厂商的命令行工具、包管理器、静态站点生成器。CLI 作为商业产品,意味着你要考虑的不只是“能用”,而是安装方式、升级策略、错误信息质量、文档体验、甚至好看不好看——是的,终端里的视觉体验也很重要。
谁最需要这份内容?我写这篇东西,面向的是三类人:一是后端或全栈工程师,想给项目配上统一的命令行入口;二是 DevOps/SRE 同学,天天跟脚本打交道,想让运维操作更标准化;三是打算做开发者工具产品的人,想搞清楚一个成熟的 CLI 到底应该长什么样。
2. 命令行工具的设计:先想清楚再动手,比写代码重要得多
2.1 命令结构设计模式:单命令 vs 多命令
动手写 CLI 之前,第一个要决策的问题是:你的工具是单命令还是多命令?
单命令模式最简单,典型的例子是grep、jq这类工具,一个命令配合若干参数完成一件事。多命令模式则类似git、docker、npm,由一个主命令加上若干子命令构成。选哪个不能拍脑袋,核心判断标准是:你的工具是否覆盖了多个可以独立使用的操作?
如果你发现自己需要在命令里加一个--action或--command参数来区分不同操作,那基本上你就需要多命令结构了。我见过太多半吊子设计:一个工具,靠参数模拟子命令,比如mytool --do-build和mytool --do-deploy分开用。这种设计的问题在于:参数之间互相排斥、帮助信息混乱、补全逻辑没法做、用户记命令得靠猜。
正确做法是把动词直接作为子命令:
mytool build mytool deploy mytool rollback子命令设计的粒度也需要注意。太粗会导致一个命令承担太多职责,太细又会产生一堆意义模糊的小命令。我个人常用的标准是:一个子命令应该对应一次完整的心智操作。比如build是一次完整的操作,build-only和build-with-cache-clean就应该被参数化合并成一个命令加一个 flag。
2.2 参数定义的艺术:位置参数、选项与子命令的平衡
参数设计是 CLI 使用体验的核心,也是新手最容易出问题的地方。我见过最糟糕的参数设计是这样:tool a b c d——四个位置参数,全靠位置记住谁是谁。虽然老 Unix 工具确实这么干,但那是因为历史原因,不是因为它好。
现代 CLI 参数设计有三个原则:
原则一:必须的参数用位置参数,可选的信息用选项。比如git commit -m "message",-m是选项,因为如果没有它,交互式编辑器会接管,并不影响命令被调用。而cp source target里的source和target是必须的,所以是位置参数。
原则二:选项要提供长形式和短形式。短形式(如-v)便于快速输入,长形式(如--verbose)便于阅读和理解脚本。
原则三:互斥的参数要在设计时就避免。如果一个工具同时接收--file和--directory并在内部做分支,这通常是设计缺陷的信号。应该拆成两个不同的子命令,或者让一个参数同时兼容文件和目录。
写参数解析代码的时候,我强烈建议使用成熟的参数解析库,而不是自己去解析os.argv。手动解析参数真的是一个巨大的坑,你会遇到--flag=value和--flag value两种写法不一致的问题,会遇到短参数合并的问题,会遇到--之后参数的问题。这些坑生态里早就填平了,不要自己再踩一遍。
2.3 输出设计:stdout、stderr 与退出码的黄金分割
CLI 输出这块,我见过太多工具做得一塌糊涂。很多初学者的做法是:把所有信息全部print到屏幕上。实际上,一个设计良好的 CLI 对输出有非常严格的分工。
stdout 只负责“结果数据”,stderr 负责“过程信息和错误信息”。这句话请刻在脑子里。为什么?因为 stdout 是要被管道传输、被重定向到文件的。如果你的工具在输出结果的同时夹杂着“正在处理第 1 个文件...”“正在连接服务器...”这类日志,那下游解析程序就会当场崩溃。
具体来说:
# 正确的做法:结果走 stdout,日志走 stderr mytool build 2>build.log # 结果仍然干干净净地留在 stdout 上,可以继续管道给 jq mytool list --json | jq '.items[0].name'退出码是 CLI 的“隐藏输出”。脚本环境中,退出码从 0(成功)到非 0(失败)之间一个整数值,是自动化判断成败的唯一依据。不同的非零值可以用来区分不同的错误类型:
# 1:一般错误 # 2:参数解析错误(很多工具的惯例) # 3:依赖缺失 # 4:网络错误 # 128 + 信号编号:进程被信号终止设计退出码时,0 和 1 必须严格遵循约定,其他错误类型如果需要细分,请在文档里明说。还有一点:任何情况下都不要用非零退出码来表示“成功但有警告”这种状态。脚本会把 0 当成成功继续执行,不 0 当成失败中止,这个二义性你会坑死用脚本调你的工具的用户。
3. 实现环节拆解:从零搭建一个真正的 CLI 工具
3.1 技术选型:Node.js vs Python vs Go
命令行工具的技术栈选择,本质上是在权衡三件事:编写效率、分发便利性、性能表现。
我三个都用过,说下我的真实感受。
Node.js 的统治者是包生态。如果工具需要跟 npm 生态或前端工具链深度集成,Node 是不二之选。commander是目前最主流的参数解析库,另外yargs也有一批忠实用户。Node CLI 的缺点是启动速度——如果你做过一个重量级依赖的 CLI,你会发现在本机跑都能感觉到明显的启动延迟。这个在低配服务器上尤为明显,通常用esbuild做打包和shebang指向一个精简的包装器来缓解。
Python 适合快速开发和数据处理类工具。argparse是标准库自带的参数解析模块,click和typer则提供了更现代化的体验。Python 的最大优势是标准库极其丰富,处理文本、网络、数据库都顺手,但如果你的用户群缺乏 Python 环境,分发就会成为灾难。常见解法是用PyInstaller打包成单一可执行文件,或者直接发布到 PyPI 让用户用pipx安装。
Go 在分发体验上是碾压级的。交叉编译直接生成一个静态二进制,扔到哪里都能跑,没有依赖地狱。cobra+viper的组合几乎成了 Go CLI 的默认标配。缺点是开发迭代速度比 Python/Node 慢,尤其是不熟悉 Go 的人。
我的选择参考表:
| 维度 | Node.js | Python | Go |
|---|---|---|---|
| 开发速度 | 快 | 快 | 中 |
| 启动性能 | 中(依赖体积大时慢) | 慢 | 极快 |
| 分发便利性 | 中(需要 Node 环境或打包) | 中(需要 Python 环境或打包) | 极好(静态编译) |
| 生态成熟度 | 极佳(npm) | 佳(PyPI) | 良好 |
| 适用场景 | 前端生态、Web 工具 | 数据处理、脚本增强 | 运维工具、系统级 CLI |
3.2 基于 Node.js + commander 的完整实现
下面我以一个实际项目为例,完整走一遍实现流程。假设我们要做一个团队内部用的构建部署工具ship,它需要支持:
ship build:构建项目ship deploy <env>:部署到指定环境ship status:查看当前部署状态- 全局选项
-v显示日志、--json输出 JSON
步骤一:初始化项目结构
mkdir ship cd ship npm init -y npm install commander推荐用 ESM 模块,如果你用 TypeScript,那顺手也配上吧。目录结构:
ship/ ├── bin/ │ └── ship.js # 入口文件,shebang + 调用 main ├── lib/ │ ├── main.js # 主逻辑,注册命令 │ ├── commands/ │ │ ├── build.js │ │ ├── deploy.js │ │ └── status.js │ └── utils/ │ ├── logger.js │ ├── api.js │ └── config.js └── package.jsonpackage.json里的bin字段是关键:
{ "name": "ship", "version": "1.0.0", "bin": { "ship": "./bin/ship.js" } }步骤二:入口文件设置 shebang 和退出码
#!/usr/bin/env node import { main } from "../lib/main.js"; const exitCode = await main(process.argv); process.exitCode = exitCode;bin/ship.js只做一件事:调用 main 并设置退出码。真正的逻辑都在lib/main.js里,这样测试的时候可以脱离进程环境,直接调用函数。
步骤三:用 commander 注册命令
import { Command } from "commander"; export async function main(argv) { const program = new Command(); program .name("ship") .description("项目构建部署工具") .version("1.0.0") .option("-v, --verbose", "输出详细日志") .option("--json", "以 JSON 格式输出结果"); program .command("build") .description("构建项目") .option("--skip-tests", "跳过测试阶段") .action(async (options) => { try { const result = await runBuild(options); outputResult(result); return 0; } catch (err) { handleError(err); return 1; } }); program .command("deploy") .description("部署到指定环境") .argument("<env>", "部署环境(dev/staging/prod)") .option("--tag <tag>", "指定版本标签") .action(async (env, options) => { // ... }); program .command("status") .description("查看当前部署状态") .action(async () => { // ... }); await program.parseAsync(argv); return 0; }注意我用的是parseAsync而不是parse。原因很简单:如果你的 action 里涉及异步操作,parse不会等待异步完成,进程会提前退出。这是 commander 使用中非常常见的一个坑。
步骤四:实现日志分级
let verbose = false; export function setVerbose(v) { verbose = v; } export function info(msg) { process.stderr.write(` [ship] ${msg}\n`); } export function debug(msg) { if (verbose) { process.stderr.write(` [ship] DEBUG ${msg}\n`); } } export function error(msg) { process.stderr.write(` [ship] ERROR ${msg}\n`); }这里我把所有日志都输出到 stderr。这是前面说的设计原则的落地:stdout 只留给最终结果。你在 CLI 工具里看到的--verbose本质上就是控制 debug 日志是否输出,而不是控制“有日志还是没日志”。
步骤五:配置管理
配置文件是 CLI 工具“像样”和“玩具”的分水岭。你要让用户能通过配置文件设置默认参数,也要允许命令行参数覆盖配置文件。
// ship.config.json(默认配置) { "defaultEnv": "staging", "registry": "https://registry.internal", "timeout": 30000 }读取配置的优先级应该是:命令行参数 > 环境变量 > 配置文件 > 内置默认值。这个优先级非常重要,很多人会搞反。
const config = loadConfig(); const finalOptions = { ...config, // 第三优先级:配置文件 ...envToOptions(env), // 第二优先级:环境变量 ...cliOptions // 第一优先级:命令行参数 };3.3 Go 版本的对照实现:cobra 快速上手
如果你偏好 Go,选 cobra 几乎不会后悔。安装:
go get github.com/spf13/cobra@latest go get github.com/spf13/viper@latest主程序结构:
package main import ( "fmt" "os" "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ Use: "ship", Short: "项目构建部署工具", Long: "ship 是一个用于项目构建和部署的命令行工具", } var verbose bool var outputJSON bool func init() { rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "输出详细日志") rootCmd.PersistentFlags().BoolVar(&outputJSON, "json", false, "以 JSON 格式输出结果") } func main() { if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }子命令定义:
var buildCmd = &cobra.Command{ Use: "build", Short: "构建项目", RunE: func(cmd *cobra.Command, args []string) error { skipTests, _ := cmd.Flags().GetBool("skip-tests") if err := runBuild(skipTests); err != nil { return fmt.Errorf("构建失败: %w", err) } outputResult("构建完成") return nil }, } func init() { buildCmd.Flags().Bool("skip-tests", false, "跳过测试阶段") rootCmd.AddCommand(buildCmd) }cobra 的一个优势是自动生成 help 信息、completion 脚本和 man page。执行ship completion bash就能给 bash 生成补全脚本。这种机能对提升用户体验帮助极大,用户不需要自己折腾补全。
3.4 交互式与非交互式模式的双轨设计
很多 CLI 工具最后都会面临一个需求:要有交互提示时很友好,但没有交互时也能被脚本调用。双模式设计是专业 CLI 的标志。
我的做法是统一提供一个辅助函数:
async function promptOrArg(value, promptText, { required = false } = {}) { if (value) { return value; } if (process.stdin.isTTY) { // 交互模式:向用户提问 const answer = await prompts({ type: "text", name: "value", message: promptText }); return answer.value; } if (required) { throw new Error(`缺少必要参数: ${promptText}`); } return null; }这个函数做的事情很简单:如果命令行参数带上了值,直接用;如果没有且终端是 TTY(说明用户是真人),就提问;如果既没有参数又不是 TTY(说明在脚本里),就报错。这保证了同一个工具既能在人工环境里交互友好,也能在 CI 里完全脚本化执行。
4. 实战中的拦路虎:我踩过的常见问题和排查技巧
4.1 参数解析的隐蔽陷阱
参数解析坑多且深,我挑几个最隐蔽的说。
陷阱一:--双横线后面的参数。有些用户需要把参数原封不动传给内部程序,比如git commit -- -m "foo"里的-m被当文件处理。所有成熟的参数解析库都支持--,但在你自己拼接 argv 传给子进程时,很多人会忘记保留原 argv 中的--位置信息,导致子进程把参数误解。
陷阱二:选项值包含空格时引号问题。这看起来是 Shell 层面的问题,但你作为 CLI 工具的作者,必须在文档里明确说明参数包含空格时应使用引号,并在错误信息里给出示例。不提示用户用引号,等于把用户扔进一个没有路灯的交叉路口。
陷阱三:整数参数的进制问题。如果你用parseInt解析数字参数,必须显式指定十进制:
const timeout = parseInt(value, 10);不写10这个参数,parseInt("08")在旧 JavaScript 环境下会返回 0,因为0开头被当成八进制。这个 bug 极其隐蔽,能让你排查一下午。
4.2 输出内容出现彩色但脚本环境报错的诡异问题
很多 CLI 为了好看,在输出里加了 ANSI 颜色码。问题是,这些颜色码进了管道或多个工具的日志文件会变成一堆\x1b[31m之类的转义序列,对日志系统是致命的。
处理思路:跟随输出目标自动禁用颜色。核心判断是 stdout 是否为 TTY:
const supportsColor = process.stdout.isTTY && !process.env.NO_COLOR;另外NO_COLOR环境变量是事实标准——只要用户设置了这个变量,工具就应该完全不输出 ANSI 转义码。这是社区成熟约定,尊重它就是尊重用户。
4.3 环境变量和配置文件优先级混乱导致的“神秘”行为
这类问题在团队里很常见:某天某人跑命令发现参数有问题,去查环境变量、配置文件、命令行参数,三处值互相覆盖,谁都说不清楚最终生效的到底是谁。很多时候问题不是逻辑错了,而是优先级规则没有落实。
我处理这个问题的办法是可以直接在 CLI 里加一个doctor或debug子命令:
ship doctor该命令输出最终生效的配置值、环境变量值、配置文件路径,并对异常值给出警告。这个命令的生产成本极低,但排查问题的效率提升立竿见影。永久性地建议你的 CLI 加上这类“元信息查看”子命令,省得每次都要靠猜。
4.4 子进程继承和超时处理
写 CLI 不可避免要调用子进程。如果你用 Node.js 的child_process.exec时没注意超时参数,子进程卡住整个工具也会跟着卡死。很多人不知道exec有默认的maxBuffer限制(之前是 200KB,新版本配置各不相同),子进程输出一大段日志就莫名报错。
我自己的实践:
import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); async function runChild(bin, args, { timeout = 30000, cwd } = {}) { try { const { stdout, stderr } = await execFileAsync(bin, args, { timeout, cwd, maxBuffer: 10 * 1024 * 1024 // 10MB 足够,但避免无限 }); return { stdout, stderr, exitCode: 0 }; } catch (err) { if (err.killed && err.signal) { throw new Error(`进程超时被终止 (${timeout}ms)`); } return { stdout: err.stdout || "", stderr: err.stderr || "", exitCode: err.code || 1 }; } }注意我用了execFile而不是exec。exec会开一个 Shell 来拼接执行命令,这既引入注入风险,又导致路径转义问题。execFile直接执行二进制并传参数组,避免了很多手指受伤的可能。
5. 进阶能力:测试、分发和文档,CLI 工具的最后一公里
5.1 对 CLI 做单元测试的正确姿势
CLI 的单元测试和普通代码测试逻辑相同,但有一个关键技巧:不要把测试挂在外层的main进程上。更稳妥的写法是导出一个run(command, args)的纯函数,接收参数,返回退出码与输出数据。
以 Node 为例用 vitest:
import { run } from "../lib/main.js"; test("build 命令在无配置时应该报错", async () => { const result = await run(["build"], { argv: [], cwd: "/tmp/empty" }); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("缺少配置文件"); }); test("--json 应该输出合法 JSON 到 stdout", async () => { const result = await run(["status", "--json"], { argv: [], cwd: "/fixtures/mock" }); expect(result.exitCode).toBe(0); expect(() => JSON.parse(result.stdout)).not.toThrow(); const data = JSON.parse(result.stdout); expect(data.state).toBe("running"); });关键技巧:mock 时间源和网络源。CLI 测试里最常见的不稳定因素:子进程真实执行、网络真实请求、时间戳随机变化。把这几个外部依赖全部注入到函数参数里,测试才能稳定重复。另外所有测试命令都不要在真实目录跑,用fs.mkdtempSync开临时目录,测试结束再删掉。
5.2 发布与分发:从“本机能跑”到“别人也用得上”
这一点是我见到的很多优秀工具最后倒下的地方:写的时候很爽,发不出来。
分发渠道的优先顺序,我建议这样:
- 包管理器发布:npm、PyPI、Homebrew 是三种主流分发方式。npm 和 PyPI 发布有完善的版本管理;Homebrew 在 macOS 用户群中几乎是最顺滑的安装方式。
- 单文件可执行:Go 的静态编译直接发布二进制。Node 可以用
pkg或esbuild打包成单文件,Python 用PyInstaller。 - 容器镜像:如果是工具需要特定运行环境,直接提供一个 Dockerfile,让用户
docker run。
发布时注意第一件事是package.json里的files字段:
{ "files": ["bin/", "lib/", "README.md", "LICENSE"] }不声明 files,npm 会把你整个项目目录打进去,node_modules 里面一堆开发依赖也跟着发出去,包体大得离谱。
版本管理上,我的原则是:CLI 工具尤其要讲清楚破坏性变更。改了参数名、改了默认行为,都算破坏性变更,必须升大版本。因为用户很可能是在脚本里调用你的命令,一个默认行为的改变会让整个流水线出问题。
5.3 文档:帮用户省时间,就是帮自己省时间
CLI 工具最容易犯的毛病:只写了--help,没有写 README;写了 README,但文档只是把--help的文本复制一遍。
好的 CLI 文档,我认为必须包含:
快速上手:5 分钟内能跑通的最小用例,让用户产生“我也行”的感觉。包括安装命令、第一个示例、预期输出。
常见用例的完整示例:真实的输入输出双语对比,而不是只列参数表格。最好附带解释为什么这么用。
故障排查:给出常见错误的完整输出以及对应的解法。调试 CLI 真的很痛苦,文档里如果能写明“看到这个报错意味着什么”,会大量减少 issue 回复量。
变更日志:每个版本改了什么、破坏性变更怎么迁移。没有 changelog 的 CLI 工具,老用户根本不敢升级。
我自己的一个实操习惯是:每加一个功能,就在 README 里同步加一个用例。代码和文档一起提交,就不存在“文档忘了跟新”的问题。
6. 长期维护中的体验优化手段
6.1 进度展示的艺术
CLI 里展示进度,有一个平衡:信息太少用户焦虑,信息太多刷屏烦人。几次实践下来,我觉得分三层即可:
- 普通用户:只显示一个动态进度条(
spinner或百分比条) - 调试模式(
--verbose):显示每步的耗时、缓存命中、关键参数 - 错误时:必须把当前正在做什么的具体信息打出来
Node 里我用ora做 spinner,Go 里可以用bubbletea或progressbar。但请注意:无论怎么展示,进度输出必须走 stderr。或者通过与 TTY 判断,因为进度展示在非 TTY 的管道里没有任何意义。
6.2 错误信息的自查准则
每次写完工具,我都会刻意制造错误场景,检查错误信息是否满足三条标准:
- 告诉用户这个错误是什么:不要只说“失败”,要具体说“连接 registry 超时(10s 内无响应)”
- 告诉用户可能的原因:可以列一两条最常见的原因
- 告诉用户下一步做什么:给出建议命令或修复动作
举例对比:
// 差劲的写法 错误: 构建失败 // 合格的写法 错误: 构建失败:找不到入口文件 src/index.js 可能原因: 项目结构变更或路径配置错误 建议: 运行 `ship doctor` 检查项目配置,或在配置文件中设置正确的 entryPoint这种错误信息刚写时会觉得啰嗦,但用起来就知道它是救命稻草。用户的每个报错工单,一大半是“信息不足导致误读”,把错误信息写清楚,能直接把工单量砍半。
6.3 自动补全:成本低、收益高的用户粘性项
自动补全其实不难做,但很多开源 CLI 都忽略了。如果你用 commander,内置program.configureHelp和program.enablePositionalOptions()的配合有点麻烦,但直接用npm install @commander-js/extra-typings提供的programArgument元数据,或者干脆生成一份completion子命令:
ship completion bash > /etc/bash_completion.d/ship ship completion zsh > ~/.zfunc/_ship ship completion fish > ~/.config/fish/completions/ship.fish这个功能的代码量不多,主要为三种 shell 输出补全规则,但可以显著提升别人用你工具的频率。加载补全后,记住一个工具的难度就从“查文档”降到了“按 Tab”。
7. 最后分享几点真实体感
做了这么多年 CLI,我最大的心得是:好的 CLI 工具,不是“功能多”,而是“心智负荷低”。用户不需要知道你的内部结构,不需要记住各种隐藏 flag,打开终端敲几个字就能完成任务,这才是 CLI 工具存在的意义。
另外我越来越确信,CLI-Anything 这个方向特别适合团队内部工具链的演进——它要求的不是高超的算法能力,而是对工作流抽象、信息设计和细节强迫症的综合把控。这两点在你日常的开发里都可以刻意练习:今天写脚本时多花十分钟想想参数怎么起名、错误信息怎么组织、stdout 和 stderr 怎么划分,时间久了,你的 CLI 自然就能甩开平均水平几条街。
如果你正好也在做 CLI 工具,或者正准备把一个难用的内部流程封装成命令行,希望这篇文章能帮你少走一些弯路。踩坑总是难免的,但能避免的坑,就别亲手再踩一遍了。