
![]先交代清楚这个命令行待办事项应用不是什么新鲜玩法类似工具一抓一大把比如大名鼎鼎的todo.txt、taskwarrior但自己动手写一个的价值完全不一样。你能完全掌控数据结构、交互方式和存储位置也能在写代码的过程中把命令行程序设计的门道摸一遍。这篇文章会把整个项目从思路到落地的过程完整拆开包括数据模型怎么设计、JSON存储为什么够用、参数解析背后的原理、完整代码实现以及我在实际使用中踩过的坑。适合想用命令行管理日常事务的终端重度用户也适合刚学Python、想做一个不依赖图形界面小工具的初学者。不吹不黑这个项目真的能在日常工作里解决实际问题。1. 项目定位与技术设计思路拆解1.1 为什么需要一个跑在命令行里的待办工具先说一个真实场景。我在维护几台远程服务器的时候经常是SSH上去处理问题本着遇到问题当场记下来的原则过去会打开手机备忘录或者浏览器里的任务管理页面。但问题在于终端会话里切出去开个网页再切回来有时候光ssh会话就断了上下文全丢。后来我养成了在终端里记录待办的习惯写一条todo add 检查nginx错误日志比切窗口、开应用、找到输入框再打字快得多。这个痛点其实很有代表性。很多开发者的日常工作流高度依赖终端Git提交、编译、部署、查日志全都窝在命令行里。这时候一个命令行的待办工具就不只是一个玩具它能嵌进现有的工作流比如写完代码顺手记一条下班前跑一遍测试通过管道把任务列表输出到其他工具处理在脚本里自动创建、完成任务实现轻量级自动化核心价值就是两个字效率。图形界面任务管理工具的点击路径太长而你坐在终端里敲命令时输入一行文字是最自然的操作。1.2 核心需求与功能边界动手写代码之前先明确这个工具要做什么、不做什么这比直接开写重要得多。我规划的核心功能只有五个添加任务todo add 写周报 -p high -d 2025-06-01查看任务todo list能按状态过滤标记完成todo done 3删除任务todo delete 5基础属性优先级高/中/低、截止时间、标签至于不在范围内的功能我一开始就决定不做不做桌面提醒推送不做云同步不做自然语言解析不做多人协作。这些功能每加一个复杂度就上一个台阶。这个取舍背后是命令行工具的设计原则单一职责、快速响应。工具的目标不是替代Notion或者Jira而是让你在终端里用不到一秒钟的时间抓住一个任务再花不到一秒钟把它标记完成。功能多了输入变复杂反而背离初衷。1.3 技术选型为什么是Python和JSON技术选型上我做了三个决定用Python 3实现、用JSON文件存储、用argparse解析参数。这三个选择背后都有明确考量。Python 3的理由很简单跨平台、无需编译、标准库足够强大而且几乎每个开发者的机器上都有Python环境。虽然C、Rust写出来的工具性能更强但待办事项应用的性能瓶颈根本不在代码执行速度而是在人的输入速度。JSON文件存储的争议稍微大一些。有人会说正经工具应该用SQLite。但我的想法是待办数据的规模通常不会超过几百条JSON文件在这个量级下读取、写入都很快根本不需要引入数据库引擎。更重要的是JSON是纯文本用户可以直接用cat ~/.todo.json查看全部数据数据可读、可迁移、可备份。这比SQLite的二进制文件友好得多。argparse是Python标准库里的参数解析模块。用它而不是自己手写解析是为了把边界情况缺参数、非法选项、帮助信息都交给成熟方案处理。我知道有人喜欢用click、typer这种三方库但标准库的好处是零依赖脚本拿到哪台机器都能直接跑。2. 任务数据模型与存储细节解析2.1 数据模型把任务抽象成一条JSON记录设计数据模型时要考虑两件事现在需要哪些字段未来可能需要哪些字段。字段太少后期要加功能得改存储结构字段太多添加任务的输入成本就高。我最终定的结构是这样{ id: 3, title: 写周报, priority: high, status: todo, created_at: 2025-06-01 09:23:00, due: 2025-06-06, tags: [工作, weekly] }每个字段说下设计理由id整数自增用来在done、delete命令里唯一定位任务。为什么不选UUID因为UUID太长了在终端里敲todo done 7f3a9c2e-8b12-4d5e-9a6f-3c8d2e1a4b0f能把人逼疯还是todo done 7舒服。priority字符串枚举只允许high、medium、low三档。用枚举而不是数字是为了让数据可读性好。直接编辑JSON文件时看到high比看到2直观得多。status同样用字符串枚举todo、doing、done三态。区分todo和doing的意义在于有些任务你已经开始了但它还没完成列表里应该有个中间状态展示进度。created_at创建时间戳记录任务何时进入系统方便追溯。due截止日期存成YYYY-MM-DD格式的字符串就能直接按字典序比较大小不需要转换成日期对象。tags字符串数组用来做轻量分类。比如所有工作任务打上work标签生活事务打上personal标签。2.2 JSON持久化关键是把写入做成原子操作把数据存成JSON文件并不难但细节里藏着坑。这个文件我放在用户主目录下的~/.todo_data.json原因很朴素实在不想因为某个项目目录被rm -rf就丢掉所有待办记录。放在home目录天然对所有项目可见。写入这里得特别注意。最粗暴的做法是这样的每次修改任务就直接json.dump覆盖原文件。但这样做会有风险——如果写入过程中程序崩溃或断电文件就损坏了之前的数据全部丢失。正确的做法是原子写入def save_tasks(tasks): tmp_file DATA_FILE.with_suffix(.tmp) with open(tmp_file, w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2) tmp_file.replace(DATA_FILE)先写临时文件再通过replace()一次性替换旧文件。replace在同一个文件系统内是原子操作要么成功要么失败不会出现写到一半的状态。这个小技巧在文件型存储场景里非常关键成本低收益大。另外两个容易被忽略的细节编码写入时用ensure_asciiFalse否则中文会被转义成\u5feb\u901f这种形式文件就完全不可读了。读取防御读取时包一层try/except json.JSONDecodeError文件异常时给用户明确提示而不是抛出堆栈崩溃。我见过太多工具数据文件坏了就白屏的这个防御很值得加。2.3 命令行参数解析从sys.argv到子命令设计理解argparse之前先看看命令行参数最底层的形态。Python脚本拿到用户输入后参数都在sys.argv这个列表里。比如你敲python todo.py add 写周报 -p high得到的sys.argv是[todo.py, add, 写周报, -p, high]自己手工解析也不是不行但边界情况太多了用户没输入任务描述怎么办-p后面跟了非法值怎么办用户敲--help怎么办手写解析器处理这些情况代码量会翻好几倍而且错误提示一点都不友好。用argparse的子命令机制天然适合一个命令底下挂多个子动作的结构。核心配置如下def parse_args(): parser argparse.ArgumentParser(progtodo, description终端里的轻量待办工具) sub parser.add_subparsers(destcommand) p_add sub.add_parser(add, help添加任务) p_add.add_argument(title, help任务描述) p_add.add_argument(-p, --priority, choices[high, medium, low], defaultmedium) p_add.add_argument(-d, --due, help截止时间例如 2025-06-01) p_add.add_argument(-t, --tag, desttags, actionappend, help标签可以重复指定) p_list sub.add_parser(list, help列出任务) p_list.add_argument(-s, --status, choices[todo, doing, done]) p_done sub.add_parser(done, help标记任务完成) p_done.add_argument(task_id, typeint, help任务ID) p_delete sub.add_parser(delete, help删除任务) p_delete.add_argument(task_id, typeint, help任务ID) return parser.parse_args()这里值得解释的是actionappend这个参数。它允许用户通过-t work -t urgent这样连续指定多个标签每出现一次追加一个值最后得到一个列表。这是处理可选多次参数最干净的方式。另一个细节是destcommand。这个让argparse把用户敲的子命令名add、list这些存到args.command属性里程序主逻辑只要判断这个值走对应分支就行。3. 完整实现与实操全流程3.1 项目骨架与安装配置整个项目只有一个文件这是我刻意保持的简单。文件名就叫todo.py放在~/bin/目录下然后在shell配置里加个别名alias todopython3 ~/bin/todo.py在~/.bashrc或~/.zshrc里加上这一行source之后终端里直接敲todo add 买东西就能用不用带python3和路径前缀。如果你想让命令更深地融入系统可以做一个软链接到/usr/local/bin下面再给脚本加上执行权限chmod x ~/bin/todo.py sudo ln -s ~/bin/todo.py /usr/local/bin/todo这样todo就直接是个系统级命令了可以在任何目录下使用。3.2 核心功能代码逐段拆解先看整体的完整代码然后我挑核心部分做解析。全部代码不到150行基本思路是加载数据 - 操作数据 - 保存数据三步循环。#!/usr/bin/env python3 # -*- coding: utf-8 -*- todo.py - 轻量命令行待办事项工具数据存于 ~/.todo_data.json import json import sys from argparse import ArgumentParser from datetime import datetime from pathlib import Path DATA_FILE Path.home() / .todo_data.json STATUS_TODO todo STATUS_DOING doing STATUS_DONE done def load_tasks(): if not DATA_FILE.exists(): return [] try: with open(DATA_FILE, r, encodingutf-8) as f: return json.load(f) except json.JSONDecodeError: print(f错误数据文件 {DATA_FILE} 已损坏请手动检查或删除后重试, filesys.stderr) sys.exit(1) def save_tasks(tasks): tmp_file DATA_FILE.with_suffix(.tmp) with open(tmp_file, w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2) tmp_file.replace(DATA_FILE) def next_id(tasks): return max((t[id] for t in tasks), default0) 1 def add_task(title, prioritymedium, dueNone, tagsNone): tasks load_tasks() task { id: next_id(tasks), title: title, priority: priority, status: STATUS_TODO, created_at: datetime.now().strftime(%Y-%m-%d %H:%M:%S), due: due, tags: tags or [], } tasks.append(task) save_tasks(tasks) print(f已添加任务 #{task[id]}: {task[title]}) def list_tasks(statusNone): tasks load_tasks() priority_order {high: 0, medium: 1, low: 2} tasks.sort(keylambda t: (t[status] STATUS_DONE, priority_order[t[priority]], t[id])) shown 0 for t in tasks: if status and t[status] ! status: continue shown 1 mark {todo: [ ], doing: [~], done: [x]}[t[status]] p {high: 高, medium: 中, low: 低}[t[priority]] due f 截止:{t[due]} if t.get(due) else tags .join(f#{tag} for tag in t.get(tags, [])) print(f{mark} #{t[id]:3} [{p}] {t[title]}{due} {tags}.rstrip()) if shown 0: print(没有符合条件的事项。) def done_task(task_id): tasks load_tasks() for t in tasks: if t[id] task_id: t[status] STATUS_DONE t[done_at] datetime.now().strftime(%Y-%m-%d %H:%M:%S) save_tasks(tasks) print(f任务 #{task_id} 已完成干得漂亮。) return print(f找不到编号为 #{task_id} 的任务, filesys.stderr) def delete_task(task_id): tasks load_tasks() new_tasks [t for t in tasks if t[id] ! task_id] if len(new_tasks) len(tasks): print(f找不到编号为 #{task_id} 的任务, filesys.stderr) return save_tasks(new_tasks) print(f任务 #{task_id} 已删除。) def build_parser(): parser ArgumentParser(progtodo, description终端里的轻量待办事项工具) sub parser.add_subparsers(destcommand, requiredTrue) p_add sub.add_parser(add, help添加新任务) p_add.add_argument(title, help任务描述) p_add.add_argument(-p, --priority, choices[high, medium, low], defaultmedium) p_add.add_argument(-d, --due, help截止日期格式如 2025-06-01) p_add.add_argument(-t, --tag, desttags, actionappend, help标签可重复指定) sub.add_parser(list, help列出任务).add_argument(-s, --status, choices[todo, doing, done]) 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) return parser def main(): args build_parser().parse_args() if args.command add: add_task(args.title, args.priority, args.due, args.tags) elif args.command list: list_tasks(getattr(args, status, None)) elif args.command done: done_task(args.task_id) elif args.command delete: delete_task(args.task_id) if __name__ __main__: main()3.3 阶段拆解每个核心函数为什么这么写add_task的三步曲。这个函数的结构代表了整个应用的基本模式先load_tasks()拿到当前所有数据再构造新字典加入列表最后save_tasks()写回磁盘。这是最朴素、也最容易理解的数据流。注意next_id用max加default0意思是当列表为空时返回1否则返回最大id1巧妙避开了空列表取max报错的暗坑。list_tasks的排序逻辑。这个排序是我实际用了一段时间后调整出来的。初始版本按创建时间排列但很快发现完成的旧任务堆在最前面会把列表搅得很乱。最后改成了组合排序第一个条件是是否已完成False排在True前面所以未完成的任务永远置顶第二个条件是优先级高中低排序第三个条件才是id保证同场pk时后添加的显示在后面。终端列表一屏就那么几十行排序策略直接决定使用体验。done_task里的标记逻辑。它只用for...if...else遍历找到id匹配的任务就改状态并立刻return。这里有个细节我踩过坑如果不return后面的任务也可能匹配同一个id导致重复操作。虽然数据正常时id是唯一的但防一手总没错。3.4 完整使用流程演示代码写完后实际在终端跑一遍整个过程是这个效果$ todo add 给老板发季度总结 -p high -d 2025-06-05 -t 工作 已添加任务 #1: 给老板发季度总结 $ todo add 缴纳水电费 -d 2025-06-01 -t 生活 已添加任务 #2: 缴纳水电费 $ todo add 阅读Python官方文档 -t 学习 已添加任务 #3: 阅读Python官方文档 $ todo list [ ] #1 [高] 给老板发季度总结 截止:2025-06-05 #工作 [ ] #2 [中] 缴纳水电费 截止:2025-06-01 #生活 [ ] #3 [中] 阅读Python官方文档 #学习 $ todo done 1 任务 #1 已完成干得漂亮。 $ todo list -s todo [ ] #2 [中] 缴纳水电费 截止:2025-06-01 #生活 [ ] #3 [中] 阅读Python官方文档 #学习 $ todo delete 2 任务 #2 已删除。 $ todo list [ ] #3 [中] 阅读Python官方文档 #学习 [x] #1 [高] 给老板发季度总结 截止:2025-06-05 #工作看见没todo done 1之后#1就沉到列表最下面了而还没干的#3保持在顶部。这就是前面排序策略的实际效果——焦点永远在最需要行动的任务上。4. 实操中的坑与避坑经验组合拳4.1 数据文件和编码问题第一个坑是我在开发早期遇到的JSON文件损坏。当时还在用最直接的写文件方式有一次机器突然断电重启后todo list直接抛了一串JSON解码异常。虽然代码最后改成了原子写入但这个经历告诉我工具虽小数据安全不能马虎。后来我在读取函数里加了JSONDecodeError的拦截文件损坏时给出明确提示总比用户对着traceback不知所措强。第二个坑是中文乱码。如果写入JSON时忘了加ensure_asciiFalse文件里全是\uXXXX转义序列手工查看和排查问题都很难受。另外脚本文件头部的# -*- coding: utf-8 -*-声明最好保留虽然Python 3默认就是UTF-8但有时配合旧版终端工具还是会出现编码问题。第三个坑和并发写入有关。如果你开了多个终端窗口同时操作可能后写入的窗口覆盖先写入的数据。对单人使用场景这个概率很低但如果你真想较劲可以在写入前检查文件修改时间或者加一个简单的文件锁。我最后的处理方式比较务实接受竞态条件但在save_tasks里加了一层临时文件机制至少保证每次写入不破坏已有数据。4.2 使用体验优化心得用了一个多月之后我陆续加了几处细节优化每处都是真实的痛点驱动。第一处是输出格式对齐。任务的id字段用了#3到#123这种宽度不一的数字列表看起来会参差不齐。格式化字符串里用f#{t[id]:3}让id右对齐并占3位输出就整整齐齐了。类似的小细节还有按[ ]、[~]、[x]的标记宽度统一都是为了让终端列表更易扫读。第二处是增加doing中间状态。命令行工具不一定只能管做没做两态。我一个任务做了一半放下第二天再看时光靠todo/doing/done三态能一眼看出哪些是已开工但没完成的避免重复安排。添加状态后我加了一个隐藏命令todo doing 3专用于把任务状态切成进行中。第三处是配合Shell做快速输入。我在.bashrc里加了这样一段t() { todo add $*; }然后终端里直接t 下楼取快递连todo add的引号都省了。命令行工具的精神就是不断减少击键次数这种alias小技巧非常实用。4.3 进阶扩展把待办嵌进工作流单一的工具只算玩具能和周边生态结合才叫生产力。这块我推荐几个经过实测的方向。在shell提示符里显示待办数量。把未完成任务数放进PS1提示符里每次回车都能看到还有几件事欠着。实现方式是在.bashrc里定义一个函数读取JSON文件行数或者用jq统计status为todo的记录数再拼接进PS1变量。实测有效果至少我没法假装看不到还剩5件事。用cron做轻量提醒。写一个shell脚本定时执行todo list -s todo -d today查到当天截止的任务就通过系统通知或者邮件发出去。这里设计时要想清楚命令行工具本身不主动提醒但可以被外部调度器驱动。保持工具被动响应、外部驱动的架构是命令行工具最优雅的形态。配合Git钩子做任务管理。比如在pre-commit钩子里检查待办列表里的进行中任务提醒自己当前分支还有事情没收尾。这种玩法极具个人特色但要让工具的数据结构足够开放。JSON天然适合被脚本读取所以扩展起来非常顺手。提示如果后续数据量超过上千条或者需要多设备同步再考虑迁移到SQLite。起步阶段用JSON完全够用不要提前优化。5. 写在最后的一点心得这个待办工具我已经在主力开发机上跑了快一年每天都会敲几次todo list。回顾整个构建过程最深的感受就是小工具的价值不在于功能多而在于顺手。它之所以还在被使用不是因为代码写得有多漂亮而是因为它融进了我已经存在的终端工作流里击键成本几乎为零。如果你也想自己动手写一个我的建议很直接不要一上来就想着把功能做全先把添加、查看、完成三个最基本的操作跑通让工具真正进入日常生活然后痛点自然会逼着你迭代。等你用烦了列表里没有优先级、没有截止日期、没有状态过滤的时候再回头加代码每一步优化都有真实的场景支撑写起来不虚。这大概就是自己动手造轮子最大的收获。