用了不下十款待办软件,从手机里的滴答清单、Trello,到桌面端的 Notion、微软待办,没一个能坚持超过两周。原因说起来很别扭:打开待办应用需要先解锁手机、找到图标、点进去,视窗再弹出来,这种摩擦足够让一个临时想法在三十秒内消失得干干净净。后来我把需求收敛成一句话——“能不能在终端里用一条命令记下事情”,于是就有了这个基于命令行的待办事项应用。它就是一个跑在终端里的待办清单,支持添加任务、列出任务、标记完成、删除任务、清空已完成项,数据保存在本机一份 JSON 文件里。没有登录、没有云同步、没有弹窗提醒,但正因为足够轻,它反而成了我每天使用频率最高的效率工具之一。
这篇文章适合这样的人:日常会打开终端敲命令的开发者,想在 shell 里顺手管理任务而不是频繁切窗口的用户,以及刚接触 Python 或 argparse 想拿小项目练手的初学者。我会先讲清楚为什么我不选择图形界面、为什么用 Python 而不用纯 shell 或 Node,然后给出完整的命令设计、数据模型和可直接复制的代码,最后把我在实际使用中踩过的问题整理成排查清单。整个项目大小不超过三百行,依赖只有 Python 标准库,跨平台可用,Bash、Zsh、PowerShell 都能跑。
1. 项目整体设计与思路拆解
1.1 为什么偏偏要做命令行待办,而不是更好的图形界面
这里的关键词不是“待办”,而是“命令行”。我观察过自己记录任务的真实场景:写代码时想到一个要修的 bug,改完一个文件后发现还需要补充测试用例,刚准备继续工作,QQ 消息弹了出来。这段时间里我真正需要的是一瞬间把念头固化下来,而不是打开一个需要登录、等待加载、还可能弹更新提示的图形应用。终端恰好具备这种随手可得的特性:光标已经在里面,直接敲todo add "给订单模块补充超时重试",回车,完事。整个过程不到两秒,而且完全不需要离开当前工作环境。
另一个让命令行方案胜出的理由是它天然适合脚本化和批量操作。图形待办应用的核心交互是鼠标点击,但命令行待办应用的核心交互是参数和管道。我可以给任务加优先级,可以一次性清理所有已完成任务,可以统计还有多少未完成事项,甚至可以在提交代码之前先跑一句todo list --done-only看看今天的进度。这种能力不是我从一开始就规划好的,而是在使用过程中自然冒出来的需求。命令行工具的魅力就在于此:它暴露给你的是可以被组合的基础能力,而不是锁死在界面里的按钮。
当然我也不是说图形版待办一无是处。它擅长展示日历、生成报告、跨设备同步,这些是纯文本界面很难做好的。但如果你和我一样,大多数任务是在电脑前产生、在终端里处理的,命令行方案反而是摩擦最小的一条路径。这个判断的依据是我过去两个月实测下来的数据:换成命令行待办之后,我对任务的记录频率从每天两三条提高到了十几次,差距非常明显。
1.2 技术选型:Python + argparse,而不是纯 shell 或 Node
确定了要做命令行工具之后,下一个问题是用什么实现。我先试过用纯 Bash 写,思路是维护一个文本文件,每行一条任务。写 add 和 list 还算顺利,到了需要查找编号、修改某一行、翻转完成状态的时候,文本处理就变得很难看。sed替换依赖正则,遇到包含特殊字符的任务文本会直接翻车;用数组和循环也能写,但代码可读性差得离谱,遇到跨平台的 GBK 编码问题更是一筹莫展。所以纯 shell 方案被我快速否定了,它适合做二三十行的胶水脚本,不适合做结构清晰的小应用。
第二个候选是 Node.js,用commander库写命令行体验很好,npm 生态里也有现成的待办库。但它的一个问题是要装运行时和依赖,哪怕用 pkg 打包成单文件,在用户的机器上分发也不如一个自带解释器的脚本省事。我的目标是用最简单、最朴素的工程方式解决问题,最好任何一台装了 Python 3 的机器都能直接运行。Python 标准库里的argparse负责参数解析,json负责数据序列化,pathlib负责跨平台路径处理,所有东西开箱即用,不需要写requirements.txt,也不需要npm install。
下面是几个主要候选方案的实际对比。我列出来不是为了争论技术栈高低,而是记录我当时的真实取舍逻辑。
| 方案 | 参数解析 | 文本持久化 | 跨平台 | 依赖成本 | 可维护性 |
|---|---|---|---|---|---|
| 纯 Bash | 很弱,靠手工解析 | 差,处理换行和转义麻烦 | 一般,Windows 体验差 | 无 | 差 |
| Node.js + commander | 好 | 好 | 好 | 需要运行时和依赖 | 中 |
| Python + argparse | 好 | 好 | 好 | Python 3 自带 | 好 |
| Go + cobra | 好 | 好 | 好 | 需要编译链 | 好但偏重 |
我最终选了 Python。还有一个不那么技术、但对我非常实际的原因:Python 的datetime和json模块处理时间戳和中文非常方便,写自定义格式化函数也比在命令行里拼字符串轻松得多。整个应用写完后是一个.py文件,我直接把它复制到任意机器的~/bin目录就能用,不需要任何额外初始化。对于个人效率工具来说,这种“零安装、零配置、零依赖”的感觉比性能更重要。
2. 核心功能与数据模型设计
2.1 命令设计:精简成五个动词加一个统计命令
命令行工具的设计核心在于把常用操作收敛成一套简单、好记、无歧义的动词。我没有照搬 GitHub 或者 Git 的多级子命令风格,因为待办应用的功能面足够窄,单层子命令是最直接的做法。最终定下来的命令集是这些:
todo add "任务内容" # 添加任务 todo list # 查看所有任务 todo done 3 # 将编号 3 的任务标记为完成 todo delete 3 # 删除编号 3 的任务 todo clear # 清空所有已完成任务 todo count # 展示未完成任务数量这个设计的思路是:动词全部对应“动作”,名词(编号)作为参数。为什么不用update、modify这类更通用的词?因为我实际使用下来发现,待办场景里高频任务就那么几类,用一个语义精准的done比通用的update更符合直觉。每次执行完操作后,程序都会打印一行反馈,比如done: #3 给订单模块补充超时重试,这样用户能够立即确认操作结果,而不是像很多 Unix 工具那样静默成功。静默在处理脚本时是优点,但在交互式命令行里,缺乏反馈会让用户产生“这条命令到底执行了没有”的疑虑。
任务文本和状态之前还有一个细节:我故意让add命令的任务内容参数不带名字前缀,直接就是位置参数。这意味着用户可以写todo add "修订 README",而不是todo add --text "修订 README"。少打一个选项名看起来是小优化,但在每天要敲几十次命令行工具的时候,省下的每一个字符都在降低使用成本。相比之下,优先级这种低频参数我选择了-p短选项,默认值是normal,只有需要时才会显式指定。
2.2 数据存储:一份 JSON 文件解决所有问题
任务数据存在哪里,是这类小工具最需要想清楚的地方。方案无非三种:内存、文本文件、数据库。内存显然不行,程序一退出数据就没了;数据库对于个人待办场景又太重,还得处理初始化、连接、SQL 语句。最终我选了本机文件存储,具体路径是用户主目录下的.todos.json。选择主目录而不是项目目录,是因为待办任务是个人数据,不应该跟某个具体项目绑定。无论我在哪个目录下敲命令,读写的位置都是同一个。
每一条任务的结构我定义成了这样:
{ "id": 3, "text": "给订单模块补充超时重试", "priority": "high", "created_at": "2025-12-11T14:32:05", "completed_at": null }字段设计基于几个朴素的判断:id是整个应用的核心索引,所有增删改都围绕它展开,所以我用了全局自增整数,简单且便于人工记忆;text存任务内容;priority支持low、normal、high三档;created_at记录任务创建时间,在列表排序和回溯时有价值;completed_at在未完成时是null,一旦标记完成就写入时间戳,这样列表展示、完成记录筛选取都非常清晰。使用null而不是用 0 或者空字符串,是 JSON 语义上最能表达“这个任务还不存在完成时间”的方式。
存储文件还承担了一个隐蔽的功能:它是整个程序的“单数据源”。所有子命令先load_tasks()读取全部任务,操作完再save_tasks()整体写回。对于一个任务量在几百条以内的待办工具,这种全量读写的性能完全不是问题,但换来的是代码逻辑极度简单——不存在索引、缓存、连接池这些概念。我见过一些同类的小工具动不动引入 sqlite,事实上它的优势只有在任务量特别大、查询条件特别复杂时才会体现,我这个场景里反而增加无谓复杂度。
3. 从零到能用的关键实现
3.1 命令行骨架先搭好:argparse 子命令的写法
整个程序的入口我用argparse的add_subparsers实现。可能有人会问:参数解析这种小事,手动sys.argv判断不就行了?对于单命令工具确实可以,但一旦命令数量超过五个,手工解析就会陷入边界条件的泥潭。比如todo done 3和todo delete 3都要接收一个整数,todo list还要可选支持--done-only,这些组合判断写起来很容易出错。argparse 把参数校验、错误提示、--help文档生成都做了,我只需要把注意力集中在业务逻辑上。
主程序骨架大概是这样的:
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse import json import sys from datetime import datetime from pathlib import Path TODO_FILE = Path.home() / ".todos.json" def load_tasks(): if not TODO_FILE.exists(): return [] with open(TODO_FILE, encoding="utf-8") as f: return json.load(f) def save_tasks(tasks): TODO_FILE.parent.mkdir(parents=True, exist_ok=True) tmp = TODO_FILE.with_suffix(".tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(tasks, f, ensure_ascii=False, indent=2) tmp.replace(TODO_FILE) def main(): parser = argparse.ArgumentParser(prog="todo", description="一个简单的命令行待办事项应用") sub = parser.add_subparsers(dest="command", required=True) p_add = sub.add_parser("add", help="添加任务") p_add.add_argument("text", help="任务内容") p_add.add_argument("-p", "--priority", choices=["low", "normal", "high"], default="normal") p_add.set_defaults(func=cmd_add) p_list = sub.add_parser("list", help="列出任务") p_list.add_argument("--done-only", action="store_true") p_list.set_defaults(func=cmd_list) p_done = sub.add_parser("done", help="标记任务为完成") p_done.add_argument("id", type=int) p_done.set_defaults(func=cmd_done) p_delete = sub.add_parser("delete", help="删除任务") p_delete.add_argument("id", type=int) p_delete.set_defaults(func=cmd_delete) p_clear = sub.add_parser("clear", help="清空已完成任务") p_clear.set_defaults(func=cmd_clear) p_count = sub.add_parser("count", help="统计未完成任务数量") p_count.set_defaults(func=cmd_count) args = parser.parse_args() args.func(args) if __name__ == "__main__": main()把func绑定到参数对象上,是一个很常见的 argparse 用法。每个子命令的解析逻辑和分析器绑定在一起,main函数最后统一调用args.func(args),这样新加一个命令只需新增一个解析器和对应的函数,不需要改主流程。如果你之前没用过这个模式,建议体会一下这个组织的妙处:它将“命令分发”和“具体实现”解耦,以后想加一个export命令,只需要再写一个函数并注册到 subparsers 里。
3.2 任务增删改查的实际代码
核心模块我拆成了五个函数:cmd_add、cmd_list、cmd_done、cmd_delete、cmd_clear,另有cmd_count作为实用补充。先看添加任务和列表展示,这是使用最频繁的两个功能。
def cmd_add(args): tasks = load_tasks() new_id = max((t["id"] for t in tasks), default=0) + 1 now = datetime.now().isoformat(timespec="seconds") tasks.append({ "id": new_id, "text": args.text, "priority": args.priority, "created_at": now, "completed_at": None, }) save_tasks(tasks) print(f"added: {args.text} (id={new_id})") def cmd_list(args): tasks = load_tasks() pending = [t for t in tasks if t["completed_at"] is None] done = [t for t in tasks if t["completed_at"] is not None] if args.done_only: pending, done = [], done for t in pending: mark = "[ ]" print(f"{mark} {t['id']:>3} {t['text']} <{t['priority']}>") for t in done: mark = "[x]" print(f"{mark} {t['id']:>3} {t['text']} ({t['completed_at']})")new_id的算法我用的是“当前最大 id + 1”。这是最自然的自增方式,能保证任务编号持续累加。有个细节是max的default=0:当文件中没有任何任务时,max()会抛异常,必须给一个默认值,这样才能计算出第一个任务的 id 是 1。
cmd_list把任务分成待办和已完成两个列表分别输出。每次展示时待办在前、已完成在后,这个顺序是故意的:你打开列表首先看到的是“现在该做什么”,而不是被一堆已完成记录占据视线。--done-only参数则用于只想检查完成记录的场景,比如复盘今天到底做了多少事。
标记完成和删除任务的处理思路类似:先根据 id 找到目标任务,找不到就打印错误并设置非零退出码。如果找到但任务已经完成,我会明确提示它已经处于完成状态,而不是重复更新。
def cmd_done(args): tasks = load_tasks() target = next((t for t in tasks if t["id"] == args.id), None) if target is None: print(f"错误:不存在编号为 {args.id} 的任务", file=sys.stderr) sys.exit(1) if target["completed_at"] is None: target["completed_at"] = datetime.now().isoformat(timespec="seconds") save_tasks(tasks) print(f"done: #{args.id} {target['text']}") else: print(f"任务 #{args.id} 已经完成了") def cmd_delete(args): tasks = load_tasks() target = next((t for t in tasks if t["id"] == args.id), None) if target is None: print(f"错误:不存在编号为 {args.id} 的任务", file=sys.stderr) sys.exit(1) tasks.remove(target) save_tasks(tasks) print(f"deleted: #{args.id} {target['text']}")使用next((t for t in tasks if ...), None)查找目标,比靠循环加标志位清晰得多。找不到时我会在标准错误输出打印信息,同时sys.exit(1)。这个细节在命令行工具里很重要:脚本和管道依赖退出码来判断命令是否成功,如果错误时也返回 0,下游脚本会误判执行结果。
清空已完成任务是一个批量写入的场景。
def cmd_clear(args): tasks = load_tasks() remain = [t for t in tasks if t["completed_at"] is None] removed = len(tasks) - len(remain) if removed == 0: print("没有可清理的已完成任务") return save_tasks(remain) print(f"cleared: 移除 {removed} 个已完成任务") def cmd_count(args): tasks = load_tasks() pending = [t for t in tasks if t["completed_at"] is None] print(len(pending))这里有一个我特别坚持的细节:所有修改操作在保存后都打印一行人类可读的确认信息,而count命令只输出一个数字。这是因为count的设计目的就是被脚本调用,比如在 shell 提示符里显示待办数量,任何额外的解释文本都会污染管道输出。
3.3 让输出更像一个真正的终端工具:颜色与退出码
一个命令行待办应用如果只是黑白文本,用起来会很闷,也不便于快速扫描优先级。我给列表展示加了 ANSI 颜色:高优先级任务用红色,中优先级用黄色,低优先级用默认色,已完成任务用绿色。颜色不是花哨装饰,而是信息分层的手段,让人一眼看出哪条任务最该先处理。
COLOR_RED = "\033[31m" COLOR_YELLOW = "\033[33m" COLOR_GREEN = "\033[32m" COLOR_RESET = "\033[0m"不过有个经验教训:不是所有人都喜欢颜色,而且某些 CI 或管道环境里 ANSI 转义序列会污染输出。我的做法是提供一个--no-color参数,同时在检测到标准输出不是终端时自动禁用颜色。实现方式可以简单判断sys.stdout.isatty():如果输出被重定向到文件或管道,就不追加颜色代码。这能避免todo list > todos.txt生成的文件里夹杂不可见字符。
退出码的设计同样值得提一下:正常操作返回 0,找不到任务时返回 1,参数校验失败时 argparse 返回 2。这样你在 shell 里执行todo done 999 && echo "操作成功",系统会根据退出码决定是否继续执行后面的命令,避免了错误被忽略的可能性。很多第一次写命令行工具的朋友会把所有输出都print到标准输出,错误也用print,这会让管道处理和脚本判断全都失灵。区分标准输出和标准错误,是一个终端工具走向专业的起点。
4. 我把项目部署到日常环境的过程
4.1 放入 PATH 与别名绑定
代码写完后,第一步是让todo命令在任何目录下都能直接执行。我把文件保存为todo.py,然后复制到/usr/local/bin/todo,再执行一次chmod +x /usr/local/bin/todo。如果你的 Python 位于~/bin这种非系统目录,记得把它加进PATH。Windows 用户则可以把脚本放入某个目录,然后在系统环境变量里添加该目录,PowerShell 同样可以调用todo。
比 PATH 设置更提升日常体验的是别名绑定。全名todo已经不算长,但我还是加上了一组短别名,因为我发现在真实场景里,少敲字符对习惯养成的影响远比自己想象中大。
alias t=todo alias tl='todo list' alias ta='todo add'把这三行写进~/.bashrc或~/.zshrc后,我记录一条任务的完整操作变成了ta "回复项目邮件",查看列表变成tl,几乎和呼吸一样自然。我还给 Git 提交流程配置了一个联动:提交代码之前先运行todo list --done-only,顺便确认今天真正完成了什么,这比临时想“我今天到底干嘛了”可靠得多。
4.2 和 shell 提示符、终端习惯的整合
使用一段时间后,我开始希望待办数量出现在眼睛常看的位置。第一选择是 shell 提示符。在 Bash 里可以通过$()嵌入命令输出,但每次渲染提示符都执行一次todo count会拖慢终端响应,即使只是几毫秒,也会让提示符变得“粘手”。所以我只在手动需要时才跑todo count或者tl,不强行塞进PS1。
如果你用 zsh 并且装了 starship 这类现代化提示符工具,可以配置一个自定义模块来显示待办数量。starship 的custom段支持指定命令,每次渲染提示符时执行并捕获第一行输出。配置大概是这样的:
[custom.todo] command = "todo count" when = true format = " [$count]($color) "要注意的是,我实际配置完发现,只要任务数不是极端庞大,这个命令的耗时在可接受范围内。但如果你追求极限性能,可以给count函数加一个缓存文件,比如任务文件变化时才重新统计,否则直接读缓存。这个优化不是必须的,但它让我们看到“命令行工具 + 提示符生态”的一个组合思路:待办应用不只是孤立的工具,它可以深度嵌入工作流。
4.3 跨平台运行的坑与处理思路
我在 macOS 上写完第一版后,把同一个文件放到 Windows 上跑,最先暴露的是编码问题。PowerShell 终端默认可能使用 GBK 或 UTF-8 之外的编码,直接输出中文会出现乱码。解决方法是:文件读写时显式指定encoding="utf-8",同时输出时用PYTHONIOENCODING=utf-8环境变量兜底。如果你用 Windows Terminal,建议在配置里把默认编码切到 UTF-8,现在这种环境已经比几年前好很多。
另一个跨平台差异是路径。Path.home()在 Windows 上会生成C:\Users\名字\.todos.json,在 Linux 上生成/home/名字/.todos.json,这一层由pathlib自动处理,不需要手动拼接字符串。这点我很有感触:以前写 Python 脚本用字符串拼路径,遇到\转义问题搞得头大,换pathlib后所有平台差异都被封装掉了。跨平台工具里的文件操作,强烈建议直接使用pathlib。
5. 常见问题与实操排查
5.1 数据文件损坏与原子写入
我最早写的版本是直接open(TODO_FILE, "w")写文件,直到有一次终端崩溃,我再次打开任务列表时发现文件变成了半截 JSON,整个应用直接报错。这个教训让我意识到:直接覆盖写文件在异常中断时会造成数据损坏。解决办法是原子写入:先写到同目录的临时文件,再通过os.replace或者Path.replace进行重命名覆盖。因为重命名在大多数文件系统上是原子操作,应用崩溃时要么是旧文件完整,要么是新文件完整,不会出现一个写了一半的中间状态。
tmp = TODO_FILE.with_suffix(".tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(tasks, f, ensure_ascii=False, indent=2) tmp.replace(TODO_FILE)即使发生了最坏情况——JSON 文件只剩一半内容,也可以手动补救。任务文件是纯文本,用任意编辑器打开,把缺失的方括号补上,或者直接删掉损坏文件并从备份恢复。毕竟待办数据的价值没有高到需要引入数据库事务,但用原子写入这种几行代码就能实现的防护,还是很值得的。
5.2 中文乱码、Shell 引号丑与长文本输入
中文乱码问题我在 4.3 提到过,这里补充一个更隐蔽的场景:如果你在 Windows 的旧版cmd.exe里运行 Python 脚本,即使文件读写指定了 UTF-8,命令行参数本身也可能会被系统按 GBK 解码。现代的 Python 3 在处理sys.argv时会尝试使用系统编码,Windows 上通常还是能正确拿到中文字符串,但输出终端如果不是 UTF-8 就会显示成乱码。最稳妥的办法是优先使用 Windows Terminal 或 VS Code 集成终端,并把终端的代码页切换到 UTF-8。
Shell 引号是另一个高频新手问题。任务文本里如果包含空格,必须用引号包住:todo add "分析接口返回值"。如果你忘了引号,shell 会把分析接口返回值当成两个参数,最终只存下第一个词,第二个词成为多余参数导致报错。如果你要输入的任务文本本身就包含引号,在 Bash 里可以用反斜杠转义,或者干脆将整条命令写进双引号再处理引号,但经验是:绝大多数任务文本根本不需要引号内的引号。
更麻烦的是长文本和换行输入。我曾经想记录一个包含三个步骤的复杂任务,比如“1. 整理接口文档 2. 更新架构图 3. 发邮件给团队”,如果全塞进一行,列表展示会很难看;如果拆成三条任务,又失去了关联性。我的方案是支持换行文本:在 Bash 里用$'...'语法,任务文本里的\n会解释成换行;在 Python 的 JSON 存储里,它天然支持带换行的字符串。列表展示时遇到多行文本,我会在每行前面加缩进,保证格式不散乱。
5.3 并发执行时的文件冲突与备份习惯
因为待办文件只有一个,理论上如果我在两个终端里同时跑todo add,可能出现竞争:两个进程同时读取同一个旧文件,各自加任务,最后写回时互相覆盖。这个问题在单人工具里极其罕见,但如果你像我一样在多个终端面板里操作,也不是完全不可能。
最简单的处理是接受这个限制:个人待办应用本身就不会在同一秒内并发写数据,遇到覆盖时损失顶多是一条任务记录。如果真想要稳妥,可以给文件操作加一个简单的锁文件,或者先构建一个足够健壮的原子写入流程。原子写只保证了文件不损坏,不能解决“丢失更新”,两者要区分清楚。我的建议是不为这个特性引入额外复杂度,但养成定期备份~/.todos.json的习惯:我每周将它复制到带时间戳的文件里,偶尔想回溯一周前的任务时还能用。
备份的操作也很命令化,比如:
cp ~/.todos.json ~/.todos.json.backup-$(date +%Y%m%d)这条命令配合 cron 或计划任务就能实现自动备份。对个人工具来说,这种用命令行原生能力完成扩展的方案,比在应用里手写一个云同步要朴素得多,也可靠得多。
5.4 命令忘干净了怎么办:自带的帮助文档
无论应用设计多精简,总有几个月之后忘记某个参数的时候。argparse 自动生成的--help在这里起了大作用。运行todo --help,可以快速看到全部子命令及其用途;运行todo add --help,可以看到该命令支持的参数和默认值。这个特性是免费的,但前提是每个子命令都写了 help 文本。我在新建命令时总是提醒自己,多花十秒钟写的描述,后面可能会节省十分钟的翻源码时间。
我在实际使用中还养成了一个习惯:把最常用的用法压缩成几行,写在.todos.json旁边的README文件里,或者干脆通过todo命令的第一个参数支持todo help输出短提示。不过考虑到复用标准帮助已经是绝大部分人的需求,这个自定义帮助命令其实没有太大的必要性。小工具的核心价值是解决问题,而不是提供越多的文档越有价值。
做了这个命令行待办应用之后,我最大的体会是:工具的成功不在功能多,而在使用阻力小。以前我总想给待办软件加提醒、加标签、加子任务,觉得功能齐全才叫专业;可真到每天都要用的时候,发现一瓶最简单的清单反而最耐用。你可以把这个项目看作一个起点,后续想加截止日期、优先级过滤、多项目管理,都是在现有数据结构上做增量。最核心的那条设计原则永远不会变:让记录任务成为一个几乎无意识的动作,这样才能坚持记录,才能真的把事情做完。