
我手机里装过的待办软件两只手数不过来。Things 的交互确实精致但价格让我犹豫TickTick 功能全到让我有点焦虑Todoist 不订阅就感觉处处受限Notion 更夸张为了记一条买牛奶我得先想清楚这个数据库该按什么属性归类。折腾了几个月之后我得出一个非常反直觉的结论最简单、最可靠的待办工具不是任何 App而是我自己在终端里写的一个命令行应用。这个基于命令行的待办事项应用CLI Todo App不是一时兴起的玩具。它平时安静地躺在我的 PATH 里想记事情就敲一行todo add 给客户回邮件 --due 2024-06-03 --priority high想看清单就todo list做完就todo done 12。没有弹窗没有角标没有您有一件任务即将到期的通知轰炸数据就是一个 JSON 文件备份直接拷走想用 Git 管版本也行。这篇文章会从需求分析讲到技术选型再到核心实现、跨平台排坑、测试打包完整记录我构建这个工具的全过程。适合三类人想在终端里管好日常任务的开发者、想找一个完整项目练手命令行应用开发的新手以及厌倦了大而全软件、想把数据抓在自己手里的效率控。1. 为什么我不再用待办 App而是自己写一个命令行版本1.1 从第 8 个待办 App 说起我复盘过自己为什么反复卸载待办软件原因可以归结成一句话它们解决的都是怎么管理时间的问题而我需要的只是怎么不忘记一件事。大多数待办 App 的问题不在功能少而在功能多。标签系统、子任务、项目分组、看板视图、习惯打卡、日历联动、团队协作、订阅制云同步……每多一个功能我就多一份维护成本。我手机上装过 8 个同类软件每一个都经历了新鲜感—整理任务—整理功能—懒得打开—卸载的循环。问题出在哪出在我得先花时间喂这个软件它才开始为我服务。而我的真实需求只是花三秒钟把脑子里那件怕忘掉的事写下来然后该干嘛干嘛。还有一层是数据归属问题。商业 App 的数据存在别人的服务器上哪天服务关闭、账号被封、或者我就不想续费了这些年记下来的事项就全没了。我的待办事项不是社交动态没有理由交给一个商业公司保管。1.2 命令行在待办场景里的真正优势想通了这一点之后我意识到命令行恰好是待办场景的天然解。原因有四条启动速度是碾压级的。点开一个 App、等启动页、等同步和直接在终端敲一条命令体验差距大约是 5 秒比 0.3 秒。对于随手记录这个动作来说0.3 秒决定了你会不会懒得记。天然可脚本化。待办的下一步动作无非是筛选、统计、提醒这些在命令行里可以用管道和循环轻松组合。todo list | grep work | wc -l这种操作在 GUI 里你得点好几层菜单。数据 100% 掌握在自己手里。一个 JSON 文件或者一个 SQLite 文件就是全部路径、格式、备份方式都由我定。SSH 场景无敌。我经常需要登录服务器处理事情在远端终端里敲todo add和在本机没有区别这是任何同步方案都给不了的原生体验。1.3 这个项目适合谁不适合谁也得说清楚边界。如果你的需求是和家人共享购物清单跟团队同步任务进度日历上要有可视化安排那老老实实用成熟商业产品别自己造轮子——那些场景的复杂度远超个人待办的范畴。但如果你是下面的情况自己写一个非常划算开发或运维人员日常 80% 时间在终端里有自己强烈的工作流习惯不想被软件预设的逻辑绑架想通过一个真实项目完整走一遍命令行应用从设计到落地的流程。这个项目规模不大不小恰好能把 CLI 解析、数据持久化、跨平台兼容、测试、打包这些知识点全串起来做完会很有成就感。2. 技术选型为什么是 Python为什么数据存 JSON2.1 语言选择的横向对比命令行工具的语言选型我看重的依次是开发速度、标准库覆盖度、跨平台一致性、分发成本。当时我认真比过四个选项方案优点缺点我的结论Python标准库自带 argparse/json/sqlite3读写文件非常省事Windows/macOS/Linux 行为一致分发需要装解释器但个人使用不存在这个问题最终选择Node.js前端背景友好npm 生态庞大启动有额外开销单文件工具写起来像在搬箱子没选Go编译单二进制、启动极快、无依赖开发迭代速度略慢处理 JSON 的样板代码偏多没选Shell简单场景几行就能跑一旦涉及参数解析、结构化存储、异常处理就失控只做辅助我选 Python 的核心理由是低摩擦。这个工具是我自己天天要用的不是产品所以开发效率优先。团队里如果有人想改个功能Python 的学习门槛也最低。而且从 Python 3.9 开始Path.home()、argparse、json这些我需要的模块全在标准库里不需要装第三方依赖就能跑起来。2.2 存储方案对比文本、JSON、SQLite接下来是数据怎么存。我列了三种常见方案的对比方案优点缺点适合场景纯文本人类可读可 grep备份简单字段一多就变成字符串解析地狱极简清单JSON结构清晰Python 原生支持数据量大后读写慢并发安全弱个人 1000 条以内SQLite并发安全支持 SQL 查询引入数据库概念备份稍麻烦数据量大、多端访问个人待办的体量一年下来也就几百条记录。一条任务撑死 200 字节500 条是 100KBJSON 完全扛得住。SQLite 对这个小规模是杀鸡用牛刀而且 JSON 文件还有一个隐性优势它能放进 Git 仓库每次增删改都有历史版本这个特性我后面越用越香。2.3 项目目录与数据模型项目结构我一开始就分成三层避免所有代码堆在一个文件里后期没法维护todo/ ├── todo.py # CLI 入口只负责解析参数和打印结果 ├── core.py # 业务逻辑与命令行完全解耦 ├── storage.py # 数据读写 ├── tests/ │ └── test_core.py └── pyproject.toml这样分层的原因后面会体现core.py里不出现任何print和argparse测试可以直接调用业务函数不用走子进程。数据模型的初始设计长这样{ next_id: 5, tasks: [ { id: 1, title: 给客户回邮件, status: todo, priority: high, due_date: 2024-06-03, created_at: 2024-05-28T10:30:00, completed_at: null } ] }每个字段都是当时仔细想过的id用自增整数方便命令行快速定位status只保留三种取值todo/doing/donepriority用 high/mid/low 三档而不是数字因为数字记不住含义due_date用YYYY-MM-DD字符串而不是时间戳这个决策在后面的时区坑里帮我省了大麻烦created_at用 ISO 格式记录completed_at留给已完成状态。现在回看这个模型直到今天都没有大改。3. 核心命令实现从 add 到 done 的完整落地方案3.1 用 argparse 搭子命令骨架命令行工具的体验很大程度取决于参数解析设计。我用了标准库的argparse加子命令方式命令风格是todo 子命令 [参数]和git保持一致用起来没有学习成本import argparse def build_parser(): parser argparse.ArgumentParser( progtodo, description一个基于命令行的待办事项应用, epilog示例: todo add 买牛奶 --priority high --due 2024-06-03 ) sub parser.add_subparsers(destcommand, requiredTrue) p_add sub.add_parser(add, help添加任务) p_add.add_argument(title, nargs, help任务内容可用空格分隔多个词) p_add.add_argument(-p, --priority, choices[high, mid, low], defaultmid) p_add.add_argument(-d, --due, help截止日期YYYY-MM-DD) p_list sub.add_parser(list, help查看任务清单) p_list.add_argument(-s, --status, choices[todo, doing, done, all], defaulttodo) p_list.add_argument(-p, --priority, choices[high, mid, low]) p_done sub.add_parser(done, help标记任务完成) p_done.add_argument(task_id, typeint) p_delete sub.add_parser(delete, help删除任务) p_delete.add_argument(task_id, typeint) p_edit sub.add_parser(edit, help修改任务) p_edit.add_argument(task_id, typeint) p_edit.add_argument(--title, nargs) p_edit.add_argument(--priority, choices[high, mid, low]) p_edit.add_argument(--due, help截止日期YYYY-MM-DD) p_edit.add_argument(--status, choices[todo, doing, done]) return parser几个细节说明一下。title用nargs这样todo add 给客户回邮件 今天下午会被拼成一条任务而不是要求用户必须加引号当然引导性的示例里我还是建议用引号防止特殊符号被 shell 吃掉。子命令的命令名我刻意用最短的单词done而不是complete因为todo done 12每次少敲 4 个字符日积月累差距不小。3.2 数据层先保证不丢数据命令行工具的数据层最重要的不是性能而是绝不因为异常写入把整个数据文件搞坏。我最开始直接open(path, w)写 JSON后来模拟了一次中途断电发现文件会变成半截内容整个待办清单全废了。现在的做法是临时文件 原子替换先写到同目录的临时文件再用os.replace覆盖原文件。os.replace在 Unix 和 Windows 上都是原子操作要么旧的完整存在要么新的完整存在不存在中间状态。import json import os import tempfile from pathlib import Path def get_data_dir(): root os.environ.get(TODO_HOME) if root: return Path(root) return Path.home() / .todo def get_data_file(): return get_data_dir() / data.json def load_data(): path get_data_file() if not path.exists(): return {next_id: 1, tasks: []} try: with open(path, r, encodingutf-8) as f: return json.load(f) except json.JSONDecodeError: # 数据损坏时兜底先把坏文件备份走再初始化新文件 backup path.with_suffix(.corrupt.json) path.replace(backup) return {next_id: 1, tasks: []} def save_data(data): path get_data_file() path.parent.mkdir(parentsTrue, exist_okTrue) fd, tmp_name tempfile.mkstemp(dirpath.parent, prefix.data-, suffix.tmp) with os.fdopen(fd, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) os.replace(tmp_name, path)这里还有一个容易被忽略的点load_data遇到 JSON 解析失败时会先把损坏文件重命名备份而不是直接覆盖。这样万一真出问题还能拿着备份文件去抢救数据。另外如果担心隐私可以在写入后用os.chmod(path, 0o600)限制只有自己能读我在 Linux 上加了这行。3.3 业务逻辑增删改查的核心实现core.py里的函数都接收data作为第一个参数返回修改后的结果。这样测试可以不碰文件系统直接操作内存里的字典。from datetime import datetime def add_task(data, title, prioritymid, dueNone): task { id: data[next_id], title: .join(title), status: todo, priority: priority, due_date: due, created_at: datetime.now().isoformat(timespecseconds), completed_at: None, } data[next_id] 1 data[tasks].insert(0, task) # 新任务放最前面 return task def mark_done(data, task_id): for task in data[tasks]: if task[id] task_id: task[status] done task[completed_at] datetime.now().isoformat(timespecseconds) return task raise KeyError(f找不到 id 为 {task_id} 的任务) def delete_task(data, task_id): for i, task in enumerate(data[tasks]): if task[id] task_id: return data[tasks].pop(i) raise KeyError(f找不到 id 为 {task_id} 的任务) def list_tasks(data, statustodo, priorityNone): tasks [t for t in data[tasks] if status all or t[status] status] if priority: tasks [t for t in tasks if t[priority] priority] order {high: 0, mid: 1, low: 2} def sort_key(t): return (t[due_date] is None, t[due_date], order.get(t[priority], 1)) return sorted(tasks, keysort_key)sort_key的排序逻辑是我实际用过一段时间后才定下来的(t[due_date] is None, ...)会让有截止日期的排在前面没截止日期的沉底然后按日期升序2024-06-01这种 ISO 字符串可以直接字典序比较不需要转成时间对象日期相同再按优先级排。逾期未完成的任务会自然浮到列表顶部这比纯按添加时间排序实用得多。3.4 让代码能用几个关键设计决策有几个细节看着不起眼实际决定了一个工具好不好用。第一个是新任务插到最前面。人记下待办那一刻是最有紧迫感的新任务应该出现在列表上方。这个设计加上排序逻辑组合出来的效果是新任务默认靠前一旦有更紧迫的截止日期又会自动被排序顶到上面。第二个是删除的 id 不复用。next_id只增不减删除任务后不会让后面的任务顶上来用旧 id。这样todo done 12永远指向那个唯一的任务不会出现完了done 错任务了的情况。代价是 id 会略微稀疏个人场景完全无所谓。第三个是CLI 只做胶水活。todo.py里只负责解析参数、调用core.py、打印结果业务判断全部下沉到core.py。这个边界让后面写 pytest 测试变得非常顺畅因为不需要启动子进程直接 import 函数就行。4. 界面体验打磨终端里的一眼看清比花哨更重要4.1 状态与优先级的视觉体系功能全跑通之后我花了不少时间在看起来怎么样这件事上。命令行界面没有图形界面信息层级全靠颜色、符号和排版来表达。我定的视觉规则是状态符号颜色含义todo[ ]黄色待处理doing[~]蓝色进行中done[x]绿色已完成high 优先级任务行标题红色需要尽快处理实现用的是colorama它最大的价值是自动处理 Windows 下的 ANSI 转义序列让同一套颜色代码在三个平台表现一致from colorama import init, Fore, Style init() STATUS_MARK { todo: ([ ], Fore.YELLOW), doing: ([~], Fore.BLUE), done: ([x], Fore.GREEN), } PRIORITY_COLOR { high: Fore.RED, mid: , low: Style.DIM, }渲染一条任务时先按优先级给整行上色再在行首加上状态符号。红色高优先级任务在清单里非常扎眼配合排序逻辑每天打开终端第一眼就知道今天必须处理什么。4.2 列表展示的排版技巧排版的第一版我用的是str.ljust对齐很快发现一个坑中文是双字符宽ljust(20)按字符数补齐遇到给客户回邮件和buy milk就完全对不齐列表看起来歪歪扭扭。后来我引入了wcwidth库按显示宽度计算填充空格这个问题才解决。如果你只是粗略对齐不追求完美也可以用全角空格凑合但要精确好看wcwidth是最省事的方案。显示效果我做成这样$ todo list 1 [ ] 给客户回邮件 高优先级 截止 2024-06-03 2 [ ] 买牛奶 中优先级 截止 明天 3 [~] 整理报销单 中优先级 4 [x] 写周报 高优先级 完成于 2024-05-27截止日期我会做一个人性化转换今天就是今天明天是明天3 天后是3 天后已经过去的显示已逾期 N 天。这个函数不长十几行但对使用体验的提升非常明显——每天打开清单看到已逾期 2 天比看到一串日期数字更能触发行动。长标题也有讲究。超过一定长度的标题用省略号截断避免一行太长把整个表格撑乱。我还按终端宽度做了自适应shutil.get_terminal_size()拿到宽度窄了就压缩列距宽了就放开。实测在 80 列的旧终端和 200 列的宽屏终端里都不会乱。4.3 空状态、错误输入与异常兜底空状态是最容易暴露开发者态度的地方。最开始我实现的是没有任务就什么都不打印后来觉得这太冷漠了改成了两行提示没有待办事项享受当下吧。 用 todo add 添加第一条任务别看就这么一句话实测对使用意愿的影响比想象中大。每天看到一堆任务的焦虑感和看到空清单的轻松感是两种完全不同的情绪反馈。错误输入的处理原则是给出可操作的信息而不是堆异常堆栈。比如todo done 99但 id 99 不存在我不会直接抛KeyError让用户看到 traceback而是捕获后输出找不到 id 为 99 的任务可用 todo list --status all 查看当前全部任务。另外todo add不带任何参数时argparse会打印用法然后报错退出这个行为其实已经够友好我不需要额外处理。唯一要留意的是KeyboardInterrupt因为每次操作数据都会立即save_data所以不存在CtrlC 导致数据没保存的问题这要归功于前面即时落盘的设计。5. 跨平台踩坑实录编码、路径与并发写入5.1 Windows 控制台的中文乱码问题我在 macOS 上开发完自认为没什么问题结果放到 Windows 上一跑中文全变成了一堆乱码。这个坑非常经典Python 的文件读写默认是 UTF-8但我写的 JSON 也确实用 UTF-8 保存了问题出在输出环节——Windows 控制台的默认代码页是 GBK936而 Python 在 Windows 上向 stdout 写中文时用了控制台代码页两边对不上就全乱。解决方式是在程序入口处强制 stdout 重新配置为 UTF-8import sys if sys.platform win32: sys.stdout.reconfigure(encodingutf-8, errorsreplace)errorsreplace是双保险——万一遇到无法编码的字符用?代替而不是直接崩掉。这个配置只影响命令行输出不影响写文件。如果你用的是新版 Windows Terminal默认已经支持 UTF-8但在 cmd.exe 和老版 PowerShell 里这行代码还是必须的。5.2 家庭目录获取的隐性坑数据文件放在用户目录下听起来简单实际踩过一次才知道坑多深。最坑的是 Git Bash 和 WSL 这类环境它们会在环境变量里塞一个HOME而这个值可能指到虚拟环境内部路径也可能和 Windows 的用户目录完全不是一个地方。结果就是你在 cmd 里用todo add记的任务到 Git Bash 里todo list完全看不到因为两个 shell 读的是两份不同的数据文件。我的方案是提供环境变量TODO_HOME作为第一优先级见 3.2 的代码同时在文档里说明如果你同时用多个 shell 管理同一个数据就把TODO_HOME固定指到同一个目录例如setx TODO_HOME D:\todo-data。这样所有 shell 都读同一份文件问题彻底消失。这个设计还附带一个好处我后来把TODO_HOME指向一个 Git 仓库的目录每次任务变更就是一次 commit待办的历史记录也变成可回溯的了。5.3 日期解析与时区陷阱日期处理有一类经典 bug把所有时间都转成 UTC 时间戳来存储和比较结果在本地深夜时段发现今天变成了昨天。我一开始也考虑过用datetime.now(timezone.utc)存时间戳后来想通了——待办任务是本地日历日概念不是时刻概念。今天下午三点开会在东八区和西八区是不同时刻但用户要的是他所在时区的本地日期。所以最终方案是due_date只存YYYY-MM-DD字符串不存完整时间戳比较日期时直接用date.today().isoformat()拿本地日期再用字符串比较from datetime import date today date.today().isoformat() def get_due_report(task): due task[due_date] if not due: return if due today: return f已逾期 {(date.fromisoformat(today) - date.fromisoformat(due)).days} 天 if due today: return 今天 if due (date.today() timedelta(days1)).isoformat(): return 明天 return f{due} 截止因为YYYY-MM-DD格式字典序就是时间序所以字符串直接比较是完全正确的不需要解析成日期对象。用date.today()而不是datetime.now(timezone.utc).date()就是为了避开那个深夜变昨天的时区坑。5.4 并发写入与数据丢失另一个真实遇到过的问题是两个终端同时写数据。有一次我在两个标签页里几乎同时执行todo add结果其中一条任务消失了。原因是两个进程各自load_data()读到了相同的数据各自 add 一条然后各自save_data()覆盖后写的把先写的盖掉了。原子替换保证了文件不损坏但保证不了丢失更新。我的第一反应是加文件锁。Unix 下用fcntl.flockWindows 下用msvcrt.locking在每次读-改-写的整个过程中持锁import sys if sys.platform win32: import msvcrt else: import fcntl def acquire_lock(f): if sys.platform win32: msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1) else: fcntl.flock(f.fileno(), fcntl.LOCK_EX) def release_lock(f): if sys.platform win32: f.seek(0) msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1) else: fcntl.flock(f.fileno(), fcntl.LOCK_UN)但说句实话对个人使用场景两个终端同时改数据的概率极低加锁本身又引入新的复杂度比如 Windows 上锁文件没删干净导致卡死。我最终的选择是保留原子替换兜底文件损坏加锁逻辑只在实际需要时启用。如果你的使用模式是多个设备往同一个云同步目录里写那就别纠结了直接升级到 SQLite它的并发控制成熟得多。6. 从玩具到习惯测试、打包与终端工作流6.1 用 pytest 给核心逻辑上保险业务逻辑独立在core.py里的好处在写测试的时候完全体现出来了。不需要模拟命令行输入不需要临时文件直接构造数据字典调用函数import pytest from core import add_task, list_tasks, mark_done, delete_task pytest.fixture def empty_data(): return {next_id: 1, tasks: []} def test_add_task_inserts_at_front(empty_data): add_task(empty_data, [买牛奶], mid, None) assert empty_data[next_id] 2 assert empty_data[tasks][0][title] 买牛奶 def test_add_task_supports_priority_and_due(empty_data): task add_task(empty_data, [写周报], high, 2024-06-03) assert task[priority] high assert task[due_date] 2024-06-03 def test_mark_done_sets_fields(empty_data): add_task(empty_data, [做体检]) mark_done(empty_data, 1) t empty_data[tasks][0] assert t[status] done assert t[completed_at] is not None def test_list_filters_by_status(empty_data): add_task(empty_data, [A]) mark_done(empty_data, 1) add_task(empty_data, [B]) assert [t[title] for t in list_tasks(empty_data, todo)] [B] assert [t[title] for t in list_tasks(empty_data, done)] [A] def test_delete_task_removes_by_id(empty_data): add_task(empty_data, [临时任务]) delete_task(empty_data, 1) assert empty_data[tasks] []这些测试帮我抓住过一次真正的回归有一次我想改进排序把due_date为空的排在最后结果不小心反过来写成空日期排最前是test_add_task_inserts_at_front这个看似和排序无关的测试先报了错。从那以后我就坚信哪怕是小工具核心逻辑也要锁定测试。6.2 打包成全局命令脚本写好后不能每次都用python3 /path/to/todo.py调用太长了。标准做法是用pyproject.toml声明一个 console script[build-system] requires [setuptools68] build-backend setuptools.build_meta [project] name todo-cli version 0.1.0 requires-python 3.9 dependencies [colorama, wcwidth] [project.scripts] todo todo:main [tool.setuptools] py-modules [todo, core, storage]然后一行命令安装到用户目录pip install --user -e .安装之后todo就是全局命令了。pip会自动处理入口脚本和 PATH在 Windows 上会放到Python 安装目录/Scripts/下。这里我强烈建议用-e开发模式安装这样每次改代码不用重新安装适合持续迭代。如果哪天想发给别人用一个独立可执行文件pyinstaller --onefile todo.py也能打包但我的使用场景是只有自己用pip install的方式更干净。6.3 与 shell、cron 联动工具落到 PATH 里玩法就开始变多了。我实际在用的几个联动每天早上 9 点自动把逾期任务推送到桌面通知。写一个~/.todo_remind.sh#!/bin/bash overdue$(todo list --status todo | grep 已逾期) if [ -n $overdue ]; then echo $overdue | notify-send 待办提醒 # Linux # macOS 用 osascript -e display notification ... fi加进 crontab0 9 * * * ~/.todo_remind.sh统计本周完成量todo list --status done --since this-week | wc -l--since这个参数是我后来加的用于过滤completed_at的日期范围代码和--status类似多一个日期比较条件而已。还有一个我很喜欢的小技巧把待办命令嵌进 shell 别名。比如我在.zshrc里定义tatodo add、tltodo list --status all这样每天的输入从 9 个字符降到 3 个字符。至于重要任务自动 git commit我是在core.py的save_data之后加了一个可选回调如果检测到数据目录是 Git 仓库就自动 commit。这个功能最初只是实验后来成了我每天必用的版本历史备份。6.4 可能的扩展方向这个项目如果继续生长我给自己列了几个方向给list加--format csv/json输出方便和其他脚本对接甚至做一份日历导入用的 CSV加一个stats子命令统计完成率、每日完成数量我会故意让它不看板不图表只输出几行数字再远一点可以用正则从自然语言里提取日期比如todo 周五前 帮同事 review 代码自动把周五前解析成--due。但没有一个是必须做的——这就是个人工具最大的自由。我实际使用中最大的体会是这类工具的生命力不在于功能多而在于离手够近。它住在我每天都会路过的终端里而不是某个需要我打开才能想起的 App 里。构建这个命令行待办事项应用的过程让我重新确认了一件事真正好用的效率工具不是功能最全的那个而是愿意被你改造、并且改造起来不费劲的那个。如果你也想动手写一个我的建议是先做出来然后逼自己每天用它记真实的任务用两周之后再决定要加什么功能——你会发现大多数你以为需要的功能其实根本不需要。