如果你和我一样一天里有七八个小时泡在终端里大概率会对“打开一个GUI应用记一条待办”的操作越来越不耐烦——鼠标挪过去、点开、输入、回车、再关掉为了一条任务付出几十秒的上下文切换成本。所以我一直觉得待办事项应用最好的形态之一就是命令行工具它轻、快、能进shell工作流、能写进脚本、还能用管道和其他工具组合出各种玩法。这篇文章我就完整记录一下我是怎么用Python从零构建一个可用的CLI待办事项应用的包括存储设计、命令体系、argparse参数解析、日期解析、彩色输出、异常兜底以及最后如何把它安装成一条全局命令。项目本身不复杂但把“能用”做到“好用”的过程中值得抠的细节其实挺多。无论你是想给自己写个顺手的小工具还是想系统理解CLI应用的设计思路这篇文章应该都能给你一些参考。1. 为什么一个待办应用值得做成 CLI1.1 从一个让我烦躁的场景说起先说个真实的场景。有一次我在服务器上排查一个线上问题排查到一半同事提醒我还有一份周报要补我只好临时开浏览器、打开某个在线待办工具、新建任务、填上描述……等这一切做完我甚至都忘了刚才排查到哪一步了。那一刻我就下定决心我需要的不是一个功能丰富的待办系统而是一个能在0.3秒内完成“记录”这件事、又不会把我从终端里拽出去的工具。也许有人会说现在手机自带备忘录、各种日历App也很方便啊。但问题是当你的大部分工作场景都在终端里时任何一次“脱离终端”的操作都是额外开销。CLI应用天然贴合这种场景命令短、速度快、可以组合、可以自动化。跑一台新机器git clone下来装好立刻就能用不需要任何图形界面依赖。1.2 CLI待办应用到底适合谁这个项目适合的人群比想象中广。如果你是开发或者运维方向的学生用来练手非常合适——你会接触到参数解析、文件持久化、日期处理、异常设计这些很基础但又很实用的知识点做完之后你手里就多了一个每天都会用到的工具。如果你已经是熟手其实也可以用它来承载你的“折腾欲”比如把存储层改成SQLite加一个提醒功能配合cron使用或者把它和你的笔记系统对接。CLI工具的好处就在这里它足够简单简单到你可以放心地按自己的需求去改。至于我为什么选了Python而不是Go、Rust这类编译语言原因很实在Python标准库自带的argparse、json、pathlib已经完全够用写起来快代码量少也没有跨平台编译的麻烦。后面如果性能成为瓶颈再考虑重写也不迟但在“个人待办”这个量级上Python完全不是瓶颈。2. 项目骨架与存储层设计2.1 目录结构和模块划分工具虽小我也没有把所有代码塞进一个文件里。分模块写的好处是逻辑清晰后面加功能不用在一大段代码中间找位置。我最终的结构是这样todo/ ├── todo.py # 入口参数解析、命令分发 ├── commands.py # 各子命令的实际逻辑 ├── storage.py # 任务存储的读写 └── pyproject.toml # 安装打包配置入口文件放最小的逻辑只负责把参数解析出来然后交给对应的命令函数。命令函数专注于“做什么”存储层专注在“怎么存”。这样一个简单的分层就已经能容纳这个项目后面所有的扩展需求了。2.2 任务数据结构的字段设计任务这个实体在设计上要回答一个问题除了待办文本之外还需要什么信息我最初的版本只有文本和完成状态但用了一周就发现不够任务有轻重缓急之分有的任务有明确截止日期我还会希望知道任务是什么时候创建的。所以最终定下来一张类似这样的数据结构字段类型说明idint自增ID删除后不重复使用textstr待办文本内容prioritystrlow / normal / highdonebool是否已完成created_atstr创建时间 ISO 格式completed_atstr / null完成时间用于统计duestr / null截止日期 YYYY-MM-DD存储格式我没有选数据库直接用了JSON文件。原因很简单待办应用的数据量撑死也就几百条JSON文件可读、可手工修改、可版本控制遇到问题可以直接用编辑器打开看。当然如果你希望支持并发写入、多端同步那SQLite肯定是更好的选择但这对于这个项目的定位来说属于过度设计。2.3 JSON读写的健壮性处理存储层最不该出现的坑就是文件损坏导致整个应用崩溃。所以在设计storage.py的时候我花了些心思在异常处理上import json import os from pathlib import Path DEFAULT_DB_PATH Path.home() / .todo / tasks.json def get_db_path(): env os.environ.get(TODO_FILE) if env: return Path(env) return DEFAULT_DB_PATH def load_tasks(): db_path get_db_path() if not db_path.exists(): return [] try: with open(db_path, encodingutf-8) as f: data json.load(f) if isinstance(data, list): return data return [] except json.JSONDecodeError: backup_path db_path.with_suffix(.json.bak) db_path.replace(backup_path) print(f任务文件已损坏原文件已备份到 {backup_path}已重置数据) return [] def save_tasks(tasks): db_path get_db_path() db_path.parent.mkdir(parentsTrue, exist_okTrue) with open(db_path, w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2)这里有两个细节值得单独说一下。第一个是编码。写入时指定encodingutf-8、ensure_asciiFalse这样中文可以原样存储在文件里而不是变成一串\uXXXX转义字符文件的可读性会好很多。第二个是损坏备份。如果JSON文件因为断电、手工编辑失误等原因损坏了直接静默重置或者直接崩溃都不好我现在这个方案是把损坏文件重命名为.json.bak备份然后重新开始。这样至少不会丢失原始数据而且用户能明确知道发生了什么。3. 命令体系用 argparse 搭出的 CLI 骨架3.1 命令总览与参数设计在写代码之前我先把命令行的交互方式设计了出来。CLI工具的体验好坏很大程度取决于命令是否直觉、参数是否顺手。我最终定下来的命令集是这样命令作用示例add添加任务todo add 写周报 --priority high --due 2025-07-01list / ls列出任务todo ls --pending --priority highdone标记完成todo done 3 5rm删除任务todo rm 2clear清空已完成任务todo clear -ystats统计概览todo stats我刻意保持了命令的颗粒度一个命令只做一件事参数都尽量短。比如优先级用-p截止日期用--due查看已完成和未完成直接用--done和--pending。这样设计的好处是记忆成本低用几天之后基本不需要查看帮助文档。3.2 为什么用 argparse 而不是手撕 sys.argv有些初学者会直接去拿sys.argv做判断比如sys.argv[1] add这种做法在指令数量少的时候当然没问题但一旦命令多起来就会很痛苦参数顺序、缺参报错、帮助文档、别名支持这些全都要自己实现。argparse就是把“命令行参数转成结构化数据”这件事标准化的标准库方案。import argparse def build_parser(): parser argparse.ArgumentParser( progtodo, description一个带优先级和截止日期的 CLI 待办应用, ) subparsers parser.add_subparsers(destcommand, requiredTrue) p_add subparsers.add_parser(add, help添加任务) p_add.add_argument(text, nargs, help任务内容) p_add.add_argument(-p, --priority, choices[low, normal, high], defaultnormal, help优先级默认 normal) p_add.add_argument(--due, defaultNone, help截止日期如 2025-07-01) p_list subparsers.add_parser(list, aliases[ls], help列出任务) p_list.add_argument(-a, --all, actionstore_true, help显示全部) p_list.add_argument(--done, actionstore_true, help只显示已完成) p_list.add_argument(--pending, actionstore_true, help只显示未完成) p_list.add_argument(-p, --priority, choices[low, normal, high], help按优先级过滤) p_list.add_argument(-c, --category, help按分类过滤扩展预留) p_done subparsers.add_parser(done, help标记任务完成) p_done.add_argument(ids, nargs, typeint, help任务 ID可传多个) p_rm subparsers.add_parser(rm, aliases[remove], help删除任务) p_rm.add_argument(ids, nargs, typeint, help任务 ID可传多个) p_clear subparsers.add_parser(clear, help清空已完成任务) p_clear.add_argument(-y, --yes, actionstore_true, help跳过确认) p_stats subparsers.add_parser(stats, help显示统计信息) return parser用nargs让add命令可以接收多个文本参数然后内部用空格拼接这样todo add 配置 PostgreSQL 主从不用加引号也能正确处理。这是我在使用过程中非常满意的一个细节。另一个值得说的是别名功能。list和ls、rm和remove是可以共存的。习惯短命令的人可以用ls喜欢语义完整的人用listargparse在aliases里直接就支持了不需要额外的映射逻辑。3.3 全局选项和子命令选项的取舍设计过程中我不断问自己哪些选项应该放在全局哪些应该放在子命令里这个决定会影响用户的使用习惯。比如“数据库路径”这种配置你自然考虑放到全局参数里比如todo --db ~/myfile.json add xxx但仔细想想这又不合理——谁会每次输入一遍路径呢设置环境变量TODO_FILE就够了全局参数反而污染命令。所以我只保留了一个全局参数--version其余配置用的都是环境变量或者子命令参数。这个原则可以概括成一句话子命令参数用于“这一次操作的意图”环境变量用于“这个工具的持久配置”两者不混淆。4. 核心命令的实现细节4.1 add增量添加别搞复杂逻辑add命令的实现核心就三件事生成新ID、组装任务字典、追加保存。逻辑本身不复杂但ID生成这里我要提醒一句不要用len(tasks) 1来生成ID。因为如果你删掉了某条任务len会变小新任务可能和旧任务的ID重复这会让你在后续操作时非常困惑。我用的是最大值加一def next_id(tasks): return max([task[id] for task in tasks], default0) 1这样即使删掉ID为100的任务下一条新任务也会是101ID只会递增不会复用引用关系始终清晰。完整的add逻辑我放在了commands.py里def add_task(tasks, text, prioritynormal, dueNone): now datetime.now().isoformat(timespecseconds) task { id: next_id(tasks), text: text, priority: priority, done: False, created_at: now, completed_at: None, due: due, } tasks.append(task) return task注意created_at用的是datetime.now().isoformat(timespecseconds)这样的格式既保留了时间信息又不会精确到微秒看起来干净也方便程序解析。4.2 list过滤逻辑用组合不要层层嵌套list命令是整个工具里最容易被低估的部分。它的过滤条件可能有四五个是否完成、优先级、分类、是否过期等等。刚开始写的时候我很容易写出“先筛选状态的循环里再嵌套筛选优先级的循环”这种代码。后来我意识到既然每个过滤条件都返回一个布尔值那就可以把过滤条件全部收集起来然后用一层循环加逻辑组合搞定def filter_tasks(tasks, show_allFalse, show_doneFalse, show_pendingFalse, priorityNone, categoryNone, overdueFalse): result [] for task in tasks: if show_all: pass elif show_done and not task[done]: continue elif show_pending and task[done]: continue elif not show_done and not show_pending and task[done]: continue if priority and task.get(priority) ! priority: continue if category and task.get(category) ! category: continue if overdue: if not task.get(due): continue if parse_date_string(task[due]) date.today(): continue result.append(task) return result这段代码里的关键在于先处理“状态”这个维度的默认逻辑——默认只显示未完成的任务如果用户显式指定了--done或--pending那就按照指定的来。状态过滤完了再用continue依次处理其他条件。这样每个条件都是独立的增加一个过滤维度就加一个判断代码不会越写越乱。4.3 done / rm / clear状态变更类命令的共同套路done、rm、clear这三个命令本质上都是“状态变更”操作它们有一个共同的坑传入的ID可能不存在或者传入重复ID。我的处理方式是逐个ID处理遇到不存在的ID立刻给出警告但不中断整个流程最后再打印一个汇总def mark_done(tasks, ids): updated [] not_found [] for task_id in ids: for task in tasks: if task[id] task_id: if not task[done]: task[done] True task[completed_at] datetime.now().isoformat(timespecseconds) updated.append(task_id) else: # 已经完成的任务也能正常识别 updated.append(task_id) break else: not_found.append(task_id) return updated, not_found完成时间的记录也放在这里。completed_at平时是None只有标记完成时才写入。这样的好处是stats命令可以根据它统计“今天完成了多少条”否则所有统计都得靠任务列表的最终状态去猜不准确。rm和done的实现套路类似不过删除的时候要特别注意删除任务后其他任务的ID不要跟着变。有些人可能会想把ID重新排序比如删掉2号任务之后把3号改成2号这种做法我强烈不建议——一旦你在其他地方引用过任务ID改ID就是给自己埋雷。clear比较特殊它需要二次确认def clear_done(tasks, forceFalse): done_tasks [t for t in tasks if t[done]] if not done_tasks: print(没有已完成的待办不需要清理) return tasks if not force: resp input(f将删除 {len(done_tasks)} 条已完成任务确认[y/N] ).strip().lower() if resp ! y: print(已取消) return tasks return [t for t in tasks if not t[done]]这里用到input()来做一个交互式确认配合-y参数可以跳过这个环节。CLI工具不是不能用交互但要保证交互可以被跳过。这样你在写脚本批量执行的时候就不会被卡在确认环节。4.4 stats让工具开始有“反馈感”stats命令是我后来加上的但它很快就成了我喜欢用的功能。它不负责管理任务只负责展示全局概况def show_stats(tasks): total len(tasks) done sum(1 for t in tasks if t[done]) pending total - done today date.today() completed_today sum( 1 for t in tasks if t[done] and t[completed_at] and datetime.fromisoformat(t[completed_at]).date() today ) overdue sum( 1 for t in tasks if not t[done] and t.get(due) and parse_date_string(t[due]) today )统计输出的信息包括总数、已完成数、待办数、今天完成数、逾期未办数。这个功能让我对自己的任务量有了更直观的感知。尤其是“逾期未办数”它逼着我每天去看一眼那些拖着的任务而这不是焦虑是一种有效的提醒。5. 体验打磨日期解析、彩色输出和错误兜底5.1 灵活解析日期格式别让用户背格式日期是CLI应用里最容易让用户觉得“难用”的地方。如果只支持一种严格的ISO格式2025-07-01用户可能要经常去记格式还容易打错。我做的妥协是除了完整日期也支持today和tomorrow这种自然日期以及07-01这种简化写法from datetime import date, datetime, timedelta def parse_date_string(text): if not text: return None text text.strip() if text.lower() today: return date.today().isoformat() if text.lower() in (tomorrow, tmr): return (date.today() timedelta(days1)).isoformat() for fmt in (%Y-%m-%d, %Y/%m/%d, %m-%d): try: if fmt %m-%d: parsed datetime.strptime(text, fmt) return parsed.replace(yeardate.today().year).date().isoformat() return datetime.strptime(text, fmt).date().isoformat() except ValueError: continue raise ValueError(f无法解析日期: {text})这个函数返回的是标准格式的ISO日期字符串意味着无论用户用哪种格式输入最终存进文件里的都是统一的格式。用户在CLI里输入时可以随意一点但数据层面始终保持规范这就是“宽容输入、严格存储”的设计思路。5.2 彩色输出必须判 TTY否则管道会留下转义垃圾给任务列表染色是提高可读性的一个小技巧。优先级高的用红色已完成的用绿色普通任务用默认色。实现时我用最简单的方式——ANSI转义码import sys def set_color(text, code): if sys.stdout.isatty(): return f\033[{code}m{text}\033[0m return text关键就是这个sys.stdout.isatty()判断。如果没有这个判断你把todo ls output.txt重定向到文件时文件里就会出现一堆\033[91m这样的转义字符极其恶心。加上这个判断之后只有终端环境下才输出颜色重定向到文件时输出普通文本整个工具在管道场景下才会变得可靠。Windows下如果要支持颜色可以在入口处加一句os.system()这会让Windows 10及以上的终端启用ANSI转义支持。如果你的用户环境更老那再考虑第三方库colorama但在我实测下来现代终端里os.system()已经够用了。5.3 常见错误的提示方式CLI报错信息最忌讳的是只说“出错了”。好的报错应该告诉用户哪里错了、怎么修。我总结了三个常见场景的处理方式。一是任务文件缺失直接返回空列表不需要报错因为这是第一次运行的正常状态。二是文件损坏打印出备份位置并重置这个上文已经写过。三是用户误删ID、输入不存在的ID此时要给出明确提示if not_found: print(f警告: 以下 ID 不存在: {not_found}已跳过)如果用户用了一条不存在ID的命令只输出一个“失败”了事那用户会完全不知道该怎么办。这种明确的提示虽然只是多写了一行print但它决定了这个工具是“用起来难受的工具”还是“用起来放心的工具”。6. 发布成一条真正的全局命令6.1 用 pyproject.toml 配置 pip 安装如果只是想在自己的机器上用把todo.py放到一个目录加个alias就够了。但如果你想把它安装到全局环境里让todo在任何目录下都可以直接敲出来那pyproject.toml配合pip安装是最靠谱的方案[build-system] requires [setuptools68] build-backend setuptools.build_meta [project] name todo-cli version 0.1.0 description A minimal, pragmatic command-line todo app requires-python 3.10 [project.scripts] todo todo:main然后执行pip install .这条命令会在当前Python环境的脚本目录里生成一个todo可执行文件它指向todo.py文件里的main函数。之后再在终端里直接敲todo add 买东西就能用和系统命令没有区别。这种方式还顺带解决了依赖问题后面如果加第三方库pip install会自动处理。6.2 不想装包alias 和 PATH 方案如果你不想污染Python环境或者不想用pip安装更轻量的方案是直接在shell配置里加别名。比如在~/.bashrc或~/.zshrc中写alias todopython3 ~/path/to/todo.py这个方案的好处是零依赖坏处是每次执行都会启动一个完整的Python进程启动速度会比安装成全局命令稍慢一点。但实测下来也就慢个几十毫秒对于待办应用来说完全感受不到差别。还可以直接把todo.py放到/usr/local/bin/等PATH目录下并赋予可执行权限chmod x todo.py cp todo.py /usr/local/bin/todo需要确保todo.py文件开头有#!/usr/bin/env python3这样才可以直接执行。这个方案比alias干净也比较适合不想装包的用户。6.3 Windows 下的注意事项如果你的主力环境是Windows有几个坑值得提前避开。首先是编码问题。Windows控制台默认的代码页可能是GBK如果Python代码里直接print中文在老版本控制台上可能乱码。Python 3.7之后的print通常会直接处理但如果遇到问题可以在入口加一个环境变量import sys sys.stdout.reconfigure(encodingutf-8)其次是控制台的颜色支持。可以调用os.system()来启用ANSI颜色这在Windows 10以上的PowerShell和Windows Terminal里实测有效。最后一个是路径问题。Path.home()在Windows下会指向用户目录这没问题但如果你通过环境变量TODO_FILE指定了自定义路径建议用绝对路径避免相对路径在不同工作目录下出现歧义。7. 还能往哪扩展从够用到好用7.1 优先级排序和到期提醒目前list命令输出的顺序还是按添加顺序来的但一个“好用”的待办应用应该能把高优先级和即将到期的任务放在前面。这个逻辑我在后续版本中实现了先按完成状态分组未完成在前已完成在后未完成的部分再按优先级排序high normal low同优先级下有截止日期的排在无截止日期的前面截止日期早的排前面。PRIORITY_MAP {high: 0, normal: 1, low: 2} def sort_tasks(tasks): def sort_key(task): if task[done]: return (1, 3, 1, task[id]) priority_order PRIORITY_MAP.get(task.get(priority), 1) has_due 0 if task.get(due) else 1 due_date task.get(due) or 9999-12-31 return (0, priority_order, has_due, due_date) return sorted(tasks, keysort_key)这种排序方案不需要额外建索引也不需要修改存储结构只是在显示的时候临时排序干净利落。配合list命令默认只显示未完成的事打开的瞬间就知道“我现在最该做什么”。7.2 与系统通知联动CLI工具的一个天然优势是可以和其他系统工具组合。比如想在任务到期时收到通知我写过一个简单的联动方案在todo.py里加一个隐藏命令notify-due它扫描所有未完成任务如果有任务在N天内到期就打印一行特定格式的输出。然后配合系统的计划任务每天9点自动跑一次。在Linux/macOS上可以用cron0 9 * * * /path/to/todo notify-due --days 1 /tmp/todo_notify.log 21然后配合osascript或者notify-send发系统通知。在Windows上可以用任务计划程序每次执行一条命令即可。这样一来你不需要打开任何界面每天早上一睁眼系统就会告诉你“今天有三条任务快到期了”。7.3 数据导入导出和备份最后提一个容易被忽略但很实用的话题数据备份。既然任务数据都存在~/.todo/tasks.json里备份就变得特别简单直接复制文件就行。我提供了一个快速备份命令cp ~/.todo/tasks.json ~/.todo/backup_$(date %Y%m%d).json这个简单命令我甚至没有写进Python代码里直接靠系统命令完成。如果你用Git管理配置目录也可以把整个~/.todo目录作为一个Git仓库每次修改后提交一下历史记录都留下来了。这个效果是数据库方案给不了的——纯文本文件的另一个隐藏价值就是它天生适合版本管理。我个人的习惯是每周跑一次备份脚本本质上就是压缩复制加保留7个副本一旦某次批量操作误删数据可以快速找回。数据量不大的时候这种朴素方案比任何花哨的同步机制都可靠。7.4 我的使用心得习惯比功能更重要写了这么多技术细节最后聊点实际的体会。工具好不好用功能是一方面习惯了之后能不能形成肌肉记忆是更重要的一方面。我的体验是CLI待办应用的最大价值不是“功能强”而是它低到几乎没有的记忆成本和操作成本。你不需要研究界面不需要点击层层菜单所有的操作就是几条短命令。我的日常流程大概是这样的早上打开终端跑一个todo ls看看今天有哪些事接到新的任务请求顺手todo add xxx --priority high --due today做完一件todo done 12。一整天下来我不需要切换任何界面所有待办信息始终和我的工作环境在一起。这种感觉很奇妙就像你一直放在手边的一张便利贴而不是锁在某个App里的待办列表。如果你也想尝试类似的东西建议你直接照着本文的思路花一个下午写一版最简单的先别追求功能丰富先把add、list、done这三条命令跑通。只要它能流畅地融入你的日常操作你就再也不会想换回图形界面了。
