第 3 章 · 04 打包分发与综合 todo 工具 本节摘要: 这一节是本章的收尾,做两件事: 先讲「怎么让你的 CLI 跑出你的电脑,被别人用上」——脚本语言用包管理器分发(Python 的 pip、Node 的 npm)、编译型语言出跨平台二进制(Go/Rust 的单文件可执行),并解释为什么「单二进制」是分发的圣杯;然后把本章前三节(参数解析、接口设计、彩色输出、交互输入)全部串起来,设计一个完整的 CLI 作为综合应用——支持 add/list/done/rm 四个子命令、配置文件存默认优先级、彩色输出、可打包分发。这个 todo 工具是「造你自己天天用的工具」的模板,也是本章的全部知识点在一处汇合的「毕业设计」。读完本节,你就有了一个能天天用、能分发给同事的 CLI 小工具。
本节摘要: 这一节是本章的收尾,做两件事: 先讲「怎么让你的 CLI 跑出你的电脑,被别人用上」——脚本语言用包管理器分发(Python 的 pip、Node 的 npm)、编译型语言出跨平台二进制(Go/Rust 的单文件可执行),并解释为什么「单二进制」是分发的圣杯;然后把本章前三节(参数解析、接口设计、彩色输出、交互输入)全部串起来,设计一个完整的
todoCLI 作为综合应用——支持 add/list/done/rm 四个子命令、配置文件存默认优先级、彩色输出、可打包分发。这个 todo 工具是「造你自己天天用的工具」的模板,也是本章的全部知识点在一处汇合的「毕业设计」。读完本节,你就有了一个能天天用、能分发给同事的 CLI 小工具。
内容来源: 基于原索引「Build your own X」Command-Line Tool 域相关条目整理的导读,原始教程为外部资源(如「Build your own todo app」「Build your own task manager」等)。
阅读完本节,你应当能够:
造完一个 CLI,如果你的目标只是「自己用」,那确实就到这了——把脚本丢进 PATH,敲 mytool 就能跑。但如果你想让别人也用上(同事、开源社区、未来的自己换电脑后),就绕不开分发(distribution)这一步。
分发看着简单,其实是 CLI 工具生命周期里最容易翻车的一环:
这一连串「依赖地狱」是脚本语言分发的通病。编译型语言(Go、Rust、C)天生免疫——它们把依赖在编译时就静态链接进二进制,用户拿到一个文件,下载即用,无需关心运行时。
所以本节前半段讲分发的两条路线,以及为什么近年 CLI 工具领域 Go 和 Rust 异军突起——它们的「单二进制」特性,刚好解决了脚本语言的分发痛点。理解这点,你就明白为什么 docker/kubectl/cargo/ripgrep 这些现代 CLI 全是 Go 或 Rust 写的。
本节后半段是综合 todo 工具的设计——这是全章的「毕业设计」。前三节你学了参数解析、接口设计、彩色输出、交互输入,但这些知识点是散的。综合应用的目的,是让你把它们组装成一个完整的工具,体会这些知识点在真实工程里怎么协作、哪些地方要取舍。造完 todo,你不仅有了一个自己天天用的小工具,更重要的是建立了「从零造一个完整 CLI」的全流程经验——这种经验可以复用到任何下一个工具。
关键概念: 综合应用不是「把前面学的东西再复习一遍」,而是让你在真实约束下做取舍。比如 todo 工具要不要支持配置文件?要不要支持优先级?要不要数据库?这些问题的答案不是「越多越好」,而是「按需」——综合应用教会你克制。
路线 A: 脚本语言——包管理器分发
Python、Node、Ruby 这类脚本语言,源码就是程序本身,分发靠的是包管理器: 你把代码发到中央仓库(PyPI、npm、RubyGems),用户用 pip install、npm install -g 装到本地,包管理器负责拉依赖、放到合适的位置、把可执行入口链接到 PATH。
开发者机器 中央仓库 用户机器 your-tool (源码) PyPI / npm pip install your-tool │ ▲ │ └── 打包 (setup.py / │ ▼ package.json) ───────────────┘ 装到 site-packages, 声明依赖、入口点 入口链到 ~/.local/bin
这条路线的优点是发布简单(改版本号、跑个命令就发了)、用户安装也简单(一行 pip/npm)。缺点是要求用户机器有对应的运行时(没 Python 跑不了 Python 工具)、依赖版本可能冲突(全局装的库和你工具要的版本不一致)。
脚本语言分发的几个最佳实践:
your-tool 这个命令对应跑哪个函数」,包管理器会自动把入口链到 PATH。路线 B: 编译型语言——跨平台二进制
Go、Rust、C 这类编译型语言,分发的是编译后的二进制文件。用户不需要装任何运行时,下载一个文件就能跑。这是分发的「最高形态」——简单、独立、无依赖。
开发者机器 GitHub Releases your-tool (源码) (托管各平台二进制) │ ▲ ├── 交叉编译: │ │ GOOS=linux GOARCH=amd64 go build │ │ GOOS=darwin GOARCH=arm64 go build ────────┘ │ GOOS=windows GOARCH=amd64 go build │ ▼ 三个二进制文件: your-tool-linux-amd64, your-tool-darwin-arm64, your-tool-windows-amd64
Go 和 Rust 在 CLI 工具领域流行的原因就在这:
两条路线的取舍可以简单总结: 脚本语言(Python/Node)发布简单、可热更新,但要装运行时、依赖易冲突,适合团队内部工具与原型;编译型(Go/Rust/C)单二进制、无依赖、跨平台,但二进制大、更新要重下,适合给大众用的 CLI。这也是为什么 docker/kubectl/ripgrep 这些「面向大众」的工具全是 Go/Rust 写的——分发成本最低。
关键概念: 「单二进制」是分发的圣杯——一个文件,下载即用,无需运行时,无依赖地狱。这就是为什么 docker、kubectl、terraform、ripgrep、fd、bat 这些现代 CLI 全是 Go 或 Rust 写的。
💡 学习建议: 如果你的目标是「造一个能给大众用的 CLI」,优先考虑 Go 或 Rust——单二进制让分发成本降到最低。如果你已经熟练 Python/Node 且工具只给团队内部用,那继续用脚本语言也行,用 pip/npm 发包就够了。语言选择服从分发场景。
现在把前三节的知识点组装成一个完整的 todo CLI。这个工具的需求:
todo add "买菜"、todo list、todo done 3、todo rm 3 四个子命令。todo add "买菜" -p high),优先级有默认值,可通过配置文件改默认。这个工具的架构分五层,每层职责清晰、互不越界:
┌──────────────────────────────────────────────────┐ │ 第 1 层: 子命令分派 (CLI 入口) │ │ 职责: 解析 argv, 识别子命令, 分派到对应处理函数 │ │ 知识来源: 第 01 节 (subcommand 模式) │ ├──────────────────────────────────────────────────┤ │ 第 2 层: 选项解析与接口约定 │ │ 职责: 每个子命令有自己的选项 (-p, --all, --yes) │ │ 必选参数检查, -h 永远有效, 退出码语义 │ │ 知识来源: 第 01 节 (getopt) + 第 02 节 (接口约定) │ ├──────────────────────────────────────────────────┤ │ 第 3 层: 业务逻辑 │ │ 职责: add/done/rm/list 的实际操作 │ │ (加任务、标记完成、删除、过滤排序) │ ├──────────────────────────────────────────────────┤ │ 第 4 层: 存储与配置 │ │ 职责: 任务存哪(JSON 文件?SQLite?) │ │ 配置文件读取与三级合并 │ │ 知识来源: 第 02 节 (约定优于配置) │ ├──────────────────────────────────────────────────┤ │ 第 5 层: 输出与交互 │ │ 职责: 彩色打印 (isatty 检测), y/N 确认 (--yes 跳过)│ │ 知识来源: 第 03 节 (彩色与交互) │ └──────────────────────────────────────────────────┘
下面逐层展开设计。
入口 main 只做一件事——识别子命令,把控制权交给对应的处理函数。这是第 01 节讲的 subcommand 模式的标准骨架:
# 伪代码: todo 的入口 def main(argv): if len(argv) < 2 or argv[1] in ["-h", "--help"]: print_top_help() # 列出所有子命令 exit(0) if argv[1] == "--version": print(VERSION); exit(0) cmd = argv[1] rest = argv[2:] # 剩余参数交给子命令 commands = { "add": cmd_add, "list": cmd_list, "done": cmd_done, "rm": cmd_rm, } if cmd not in commands: sys.stderr.write(f"未知子命令: {cmd}\n") sys.stderr.write("可用: add, list, done, rm\n") exit(2) # 2 = 用法错误 commands[cmd](rest)
这一层完全不碰业务逻辑——它只是个「路由器」。
每个子命令有自己的选项解析。以 add 为例:
# 伪代码: todo add 的选项解析 def cmd_add(argv): verbose = False priority = None # 未指定时, 后面从配置取默认 text = None i = 0 while i < len(argv): arg = argv[i] if arg in ["-h", "--help"]: print_add_help(); exit(0) elif arg == "-v" or arg == "--verbose": verbose = True elif arg == "-p" or arg == "--priority": priority = argv[i+1]; i += 1 elif arg.startswith("-"): sys.stderr.write(f"未知选项: {arg}\n"); exit(2) else: text = arg # 位置参数 = 任务内容 i += 1 if text is None: # 必选参数检查 (第 02 节) sys.stderr.write("错误: 缺少任务内容\n") sys.stderr.write("用法: todo add [选项] <任务内容>\n") exit(2) # 三级合并: 命令行 > 配置 > 内置默认 if priority is None: priority = config().get("default_priority", "medium") add_task(text, priority, verbose)
注意几个第 02 节约定的落地: -h 优先响应、必选参数缺失报错退 2、未知选项报错退 2、错误信息打 stderr。
业务逻辑层是纯粹的「操作任务」,不关心参数从哪来、输出到哪去。这种「业务层与 IO 层分离」是好架构的关键——业务逻辑可以单独测试,不依赖终端环境。
# 伪代码: 业务逻辑 (无 IO, 可单测) def add_task(text, priority): task = {"id": next_id(), "text": text, "priority": priority, "done": False} store.insert(task); return task def mark_done(task_id): task = store.get(task_id) if task is None: raise NotFound(f"任务 {task_id} 不存在") task["done"] = True; store.update(task) def list_tasks(filter_done=None, sort_by="created"): tasks = store.all() if filter_done is not None: tasks = [t for t in tasks if t["done"] == filter_done] return sorted(tasks, key=lambda t: t[sort_by])
这层没有任何 print、没有 ANSI 码、没有 input——它是「纯函数」,输入参数返回结果,IO 全甩给第 5 层。这种分离让你能写单元测试直接调 add_task 验证逻辑,而不必启动整个 CLI、喂参数、捕获输出——测试既快又稳定。
存储选什么?对于一个小 todo 工具,JSON 文件就够(任务几十上百条,JSON 完全 hold 住);如果任务上几千条、要复杂查询,再升级到 SQLite。别一上来就上数据库——这是过度设计。
# 伪代码: JSON 文件存储 STORE_PATH = "~/.local/share/todo/tasks.json" class Store: def load(self): path = expand(STORE_PATH) if not exists(path): return [] return json_read(path) def save(self, tasks): mkdir_parent(STORE_PATH) json_write(STORE_PATH, tasks)
配置支持一个简单的配置文件(~/.config/todo/config.toml),存默认优先级、默认排序、是否彩色等:
# 配置文件示例 (~/.config/todo/config.toml) default_priority = "medium" default_sort = "created" color = true
配置读取遵循第 02 节讲的「命令行 > 配置 > 内置默认」三级合并——命令行显式指定的优先级最高,没指定就看配置,配置也没就用内置默认值:
# 伪代码: 三级合并 def resolve_priority(cli_priority): if cli_priority is not None: # 1. 命令行指定 return cli_priority cfg = load_config() if "default_priority" in cfg: # 2. 配置文件指定 return cfg["default_priority"] return "medium" # 3. 内置默认
💡 学习建议: 存储和配置的格式选型,遵循「够用就好」原则——JSON 存任务、TOML/INI 存配置,对 todo 这种规模完全够。等真的撑不住了再升级,别预先优化(YAGNI 原则)。
输出层负责把业务层返回的任务,渲染成彩色、对齐的列表。这是第 03 节知识的集中应用:
# 伪代码: 彩色列表渲染 def render_tasks(tasks): if not isatty(stdout) or config().get("color") == False: for t in tasks: # 非终端/禁色: 纯文本, 可被 grep 处理 mark = "x" if t["done"] else " " print(f"[{mark}] {t['id']:>3} {t['text']}") return for t in tasks: # 终端+启用色: 彩色输出 mark = green("✓") if t["done"] else gray("·") text = t["text"] if t["done"]: text = strikethrough(dim(text)) # 已完成: 暗淡+划线 elif t["priority"] == "high": text = red(text) # 高优先级: 红 elif t["priority"] == "medium": text = yellow(text) # 中优先级: 黄 print(f"{mark} {t['id']:>3} {text}")
交互层负责危险操作(rm)前的确认,且必须支持 --yes 跳过(第 03 节硬要求):
# 伪代码: rm 子命令 def cmd_rm(argv): force = "--yes" in argv or "-f" in argv # 跳过确认 task_id = parse_id(argv) task = store.get(task_id) if task is None: sys.stderr.write(f"任务 {task_id} 不存在\n"); exit(1) if not force and not confirm(f"删除 #{task_id}: {task['text']}?", default=False): print("已取消"); exit(0) store.delete(task_id); print(f"已删除 #{task_id}")
todo add 的完整路径把五层串起来,看一次 todo add "买菜" -p high 怎么流过整个工具:
[分派] argv=["todo","add","买菜","-p","high"] → 识别 cmd="add" → cmd_add(rest) [选项] 解析 text="买菜", priority="high"; 检查必选参数齐; 三级合并 [业务] add_task("买菜", "high") → 创建任务对象 [存储] store.insert(task) → 写入 ~/.local/share/todo/tasks.json [输出] 打印 "已添加 #5: 买菜 [high]" (彩色, 因 stdout 是终端) → exit(0)
每一层只做自己那块,通过清晰的接口传给下一层。这种分层是任何稍大工具都该有的骨架,不只是 todo。
造综合 todo,推荐这条「逐步加肉」的路径,每一步都能跑、都有反馈:
第一步: 最小骨架(朴素 argv + JSON 存储)。先不管子命令、不管彩色、不管配置——写一个只支持 todo add <内容> 和 todo list 的最朴素版本,任务存 JSON。约五十行代码,目标是跑通「加一条、列出来」的主干。这一步让你建立「能用的 todo」的骨架,后续往里填功能。
第二步: 加子命令分派。把入口改成 subcommand 模式,加 done 和 rm 子命令。每个子命令有自己的处理函数。这一步落实第 01 节的 subcommand 模式。
第三步: 加选项与接口约定。给 add 加 -p/--priority 选项,给 list 加 --all/--done 过滤。落实第 02 节的约定: -h 永远有效、必选参数报错、退出码语义、错误打 stderr。
第四步: 加彩色输出。给 list 的输出上色(高优先级红、已完成灰划线),加 isatty 检测实现管道去色。落实第 03 节的智能彩色。
第五步: 加配置文件与三级合并。支持 ~/.config/todo/config.toml,存默认优先级、默认排序。落实三级合并(命令行 > 配置 > 默认)。
第六步: 加交互与 --yes。给 rm 加 y/N 确认,并提供 --yes 跳过。落实第 03 节的「交互是脚本化的敌人」原则。
第七步: 打包分发。按你选的语言: Python 用 pip 发包(写 setup.py 声明入口),Go/Rust 出跨平台二进制(交叉编译后传 GitHub Releases)。附 README 和 LICENSE。
💡 学习建议: 每一步做完,都先自己用一周再继续下一步。真实使用会暴露设计缺陷——比如你会发现 list 没法按优先级排序,于是回来加排序选项;会发现 add 时间长了记不住有哪些优先级,于是回来加 list --priorities。这种「用着改」的迭代,比一次性堆功能更接近真实工程。
⚠️ 难点预警: 综合应用最容易犯的错是「贪多」——一上来就想做数据库、云同步、Web 界面。这些都是过度设计,会让项目烂尾。todo 工具的核心就是 add/list/done/rm 四个子命令加 JSON 存储,先把这核心做扎实,再按需扩展。克制是工程师的高级美德。
todo 子命令 结构,补 done/rm 两个子命令,落实第 01 节。至此,第 3 章结束。回顾本章的递进路径: 你从「怎么读 argv」(参数解析)开始,经过「怎么设计得合理」(接口约定)、「怎么让输出好看且能交互」(彩色与交互),到「怎么打包分发」与「怎么组装成完整工具」(综合 todo)。这套从输入到接口到输出到分发的全流程,是任何 CLI 工具的通用骨架——你造完 todo,就拥有了「从零造一个完整 CLI」的全流程经验,这种经验会贯穿后续章节(第 4 章正则引擎的命令行、第 9 章编译器的命令行都用得上)。本章与第 2 章(造 Shell)也形成互补: Shell 是「造一个能执行任意命令的程序」,CLI 是「造一个专门解决某问题的命令」——两者合起来,就是你在终端里的全部「自造工具箱」。下一章我们将从「用正则」上升到「造正则引擎」,理解那些 .* \d (a|b)* 背后的状态机原理。