用C语言写一个命令行待办工具:caveman的设计与实践
给自己写了一个命令行待办工具,取名叫caveman。起因很简单:手机上的待办App换了一轮又一轮,功能越来越花哨,我却越来越不愿意打开它们。后来想明白一个事——我需要的不是更多的标签、提醒、看板和团队协作,而是找个地方把“接下来要做的几件事”记下来,做完划掉。于是就有了这个用C语言写的、名字直白到不需要解释的小工具:一个可执行文件,一个纯文本文件,不联网,不装数据库,不配服务器。现在这个工具已经跟着我跑了半年,周末归档、平时随手记,前前后后帮我消化了大概两百多个零散任务。这篇文章就把整个项目拆开讲清楚:设计取舍、存储格式、核心代码、实际踩坑,全给你捋一遍。
1. 项目整体设计与思路拆解
1.1 为什么叫“caveman”:把待办清单打回石器时代
起这个名字的时候,我心里想的就是穴居人的生活状态。穴居人一天的待办清单基本上只有两件事:今天有没有猎到东西,今天晚上有没有安全的地方睡觉。现代人类的待办清单,理论上也可以简化成类似的逻辑:今天要推进哪些事,哪些事已经干完。其他的圈圈绕绕,比如把任务分成十几个子任务、给每个任务设精确到分钟的提醒、再给任务打个标签方便以后统计,这些功能对于个人日常管理来说,绝大多数都是负担。
我过去也用过不少主流工具,最后发现一个规律:整理清单本身会变成一种拖延。打开应用,先看一遍项目分组,再调两个标签,再想想这个任务应该被归到哪个看板里,等这套动作做完,十分钟过去了,正经事还没开始。caveman就是反着来的。它从设计上砍掉所有可以让人“玩工具”的机会,只剩四个动作:加一条任务、看没完成的任务、把任务标记完成、把已完成的任务归档。就是这么原始。
这种刻意限制不是倒退,而是把注意力还给事情本身。就像穴居人不会花一上午打磨一把新长矛,磨一会儿就出去打猎了。工具够用就好,剩下的时间应该花在做事情上。所以项目名caveman对我来说不是“简陋”的意思,而是“清爽”的意思。
1.2 技术选型:为什么是C语言和纯文本
可能有人会问,现在写这种小工具,用Python、Shell或者Go不是更省事吗?为什么偏偏选了C语言。说实话,选C语言有我的私心。第一,当时正好想系统地补一遍C语言的字符串处理和文件IO,与其做教材里的学生管理系统,不如写个自己真会用的工具,学习动力完全不同。第二,C语言编译出来的东西真的很小、很安静。一个几百KB的静态链接二进制,放到哪里都能直接跑,不需要目标机器上有Python解释器或者Go运行时。第三,这也是最戳我的一点:C语言写出来的程序,依赖只有标准库和操作系统提供的基础能力,几乎不会出现“昨天还好好的,今天跑不起来”的怪问题。
存储方案就更简单了。任务数据直接用纯文本,每一行一条任务,放在~/.caveman/tasks.txt。没有SQLite,没有JSON文件,没有YAML。原因也很直白:这种量级的个人待办数据,用数据库属于杀鸡用牛刀。更重要的是,纯文本是透明的,任何时刻我都可以用cat直接看内容,用grep、awk、sed做临时统计,就算程序出了bug,拿文本编辑器改两行就能修好。数据库文件一旦损坏,恢复的难度比改一行文本高出好几个量级。
从隐私角度看,纯文本存在自己电脑的隐藏目录里,不联网、不上传,也让我放心很多。我不需要为“待办事项”这种隐私数据绑定任何在线账号。老实说,这大概也是我最终能坚持用下来的原因。
1.3 功能边界:只保留四个操作
第一版功能清单我写得很克制:
add:添加一条任务list:查看全部未完成任务done:把某条任务标记为已完成archive:把所有已完成任务移出主清单
后来我自己加了两个辅助命令:show查看单条任务的完整信息,delete删除一条写错了的任务。除此之外,优先级的支持是有意的,但也只分了 P1、P2、P3 三个档位。P1 是今天必须完成,P2 是计划内要做,P3 是有空才做。
我没有做“截止日期”“循环任务”“提醒通知”“日历视图”“多端同步”这些功能。因为对我个人来说,这些东西大多数时候不是帮助,而是噪音。需要截止日期的任务,我直接在描述里写“周三前完成季度总结”;需要周期性提示的事情,交给闹钟;需要多端查看的工作安排,本来就是公司OA系统该管的事。caveman只管住我生活里那一小撮个人任务,管住这一撮就够了。
这种边界意识是整个项目最重要的设计。每次手里痒痒想加功能,我就问自己:这个功能能让我今天多做一件事吗?如果答案不确定,就不加。后来这个原则本身,反而成了我用得最久的设计。
2. 核心数据结构与命令解析
2.1 存储格式:显式ID的任务行
既然要长期使用,数据格式就不能拍脑袋定。我最早的一版用文件行号当任务ID,因为实现起来最简单,但后来发现一个问题:一旦删除掉中间的某一行,后面的行号全部往前挪,之前记下的ID就对不上了。标记完成还好,如果要精确操作某一条任务,行号就会飘。所以第二版改成了显式ID,每条任务都带着自己的编号,格式如下:
T12 [done] 2025-06-14 [P1] 修复登录页闪退每个字段的含义我都写死在解析逻辑里,顺序不能乱:
| 字段 | 示例 | 说明 |
|---|---|---|
| 任务ID | T12 | 全局唯一,单调递增,删除后不复用 |
| 状态 | open/done | 是否完成 |
| 日期 | 2025-06-14 | 添加时记录;标记完成时改成完成日期 |
| 优先级 | P1/P2/P3 | 决定的展示排序和你的注意力分配 |
| 描述 | 修复登录页闪退 | 任务的自由文本,唯一不能省略的部分 |
日期字段我特意只保留到“天”,没有精确到时分秒。个人待办不是日志,只需要知道是哪天记下、哪天做完的,精确到天足够。少一点精度,解析也多省一些力气。
默认数据文件路径是~/.caveman/tasks.txt,同时支持环境变量CAVEMAN_FILE覆盖。我用它测试时方便,换目录的时候也不用手动拷贝。在实现上,只需要在启动时这样判断:
const char *path = getenv("CAVEMAN_FILE"); if (path == NULL) { path = DEFAULT_DATA_FILE; }2.2 命令语法:从 add 到 archive
caveman的操作方式走的是Unix工具一贯的路线:一个动词,一个目标,没有交互式对话。下面是几个最常用命令的完整说明。
添加任务时,描述用双引号包起来,可以用-P指定优先级,不指定就用默认的 P2:
caveman add "准备周会材料" -P 1 caveman add "整理报销单"查看未完成任务,输出会按优先级排序,然后按ID从小到大:
caveman list输出大概是这样的格式:
T1 2025-06-14 P1 准备周会材料 T4 2025-06-15 P2 整理报销单 T7 2025-06-15 P3 给书架做一次断舍离标记完成只需要知道任务ID。比如搞定周会材料:
caveman done T1如果偶尔看花了眼,可以用show看某条任务当时的完整记录:
caveman show T4archive是维护主清单清爽度的关键命令。它会扫描所有done状态的任务,把它们追加到同目录的archive.txt,然后从tasks.txt中移除。这样日常list看到的永远只有还没做完的事,而不是一堆已经画掉的旧记录。
2.3 编译安装:从源码到可执行文件
项目结构非常简单,就两个文件:caveman.c和Makefile。我使用的是 GCC,完整编译参数放在Makefile里,方便以后在别的机器上重新构建:
CC=gcc CFLAGS=-std=c99 -Wall -Wextra -pedantic -O2 -finput-charset=UTF-8 -fexec-charset=UTF-8 LDFLAGS= caveman: caveman.c $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) install: install -m 0755 caveman ~/.local/bin/ clean: rm -f caveman这里有两个细节值得说明。一是-finput-charset=UTF-8 -fexec-charset=UTF-8,用来保证源码里直接写的中文默认字符串,比如错误提示、帮助信息,在编译后也能以 UTF-8 正常输出。二是安装目录我用了~/.local/bin,这是用户级可执行文件目录,不需要 root 权限。装好之后需要把~/.local/bin加进 PATH。如果用的是 bash,在~/.bashrc里加一行:
export PATH="$HOME/.local/bin:$PATH"然后重新登录终端或者手动执行source ~/.bashrc。接下来跑caveman add "hello caveman"就算正式上路了。
3. 实操过程:完整实现一个可用的 caveman
3.1 源码骨架与关键数据结构
整个程序不到五百行,核心是由一个结构体和一个解析函数驱动的。结构体描述了一条任务的全部字段:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <time.h> #include <locale.h> #define MAX_LINE 1024 #define MAX_DESC 900 enum status { OPEN, DONE }; struct task { int id; int status; char date[16]; int prio; char desc[MAX_DESC]; };所有命令最终都会落到三个动作上:读文件、写文件、按行解析。解析函数我写得有点轴,但胜在直接——先用sscanf抓前面的元数据,再把剩下的部分当描述:
static int parse_task_line(const char *line, struct task *t) { char status_buf[8], prio_buf[8]; int consumed = 0; if (line[0] != 'T') return -1; int scanned = sscanf(line, "T%d %7s %15s [%7[^]] %n", &t->id, status_buf, t->date, prio_buf, &consumed); if (scanned < 4) return -1; t->status = (strcmp(status_buf, "done") == 0) ? DONE : OPEN; t->prio = (prio_buf[1] >= '1' && prio_buf[1] <= '3') ? prio_buf[1] - '0' : 2; snprintf(t->desc, sizeof(t->desc), "%s", line + consumed); return 0; }这里最反直觉的是%n的使用。它不读取任何输入,而是把当前已经消费掉的字符数写进consumed变量,这样我就能知道任务ID、日期、优先级占掉多少字节,剩下的理所当然就是描述文字。因为描述里允许有空格和中文字符,用%n比用%s更稳。
3.2 核心函数:add_task 与 mark_done
add_task的逻辑是读两遍文件。第一遍扫描现有行,找出最大的任务ID并加一;第二遍把新任务追加到文件末尾。这样写虽然多一次IO,但对这个量级的数据来说,时间开销完全可以忽略。真正的收益在于,文件不存在时也能正常创建:
void add_task(const char *desc, int prio) { int new_id = next_id(); FILE *fp = fopen(data_path(), "a"); if (fp == NULL) { perror("caveman"); exit(2); } time_t now = time(NULL); struct tm *tm = localtime(&now); char date[16]; strftime(date, sizeof(date), "%Y-%m-%d", tm); fprintf(fp, "T%d [open] %s [P%d] %s\n", new_id, date, prio, desc); fclose(fp); }next_id的实现也不复杂:逐行读取文件,解析每行的T后数字,保留最大值。如果文件为空,就返回1。注意这里不能用“最后一行加一”,因为显式ID只增不减,哪怕最后一条任务已经被归档或删除,新ID也要继续往上走。
mark_done稍微麻烦一点,因为要修改文件中间的一行。最安全的方式是先读全文,在内存里把对应的行改掉,然后写临时文件,最后用rename覆盖原文件。关键操作如下:
// 伪代码示意:找到目标行,替换 [open] 为 [done],日期改成今天 for (each line) { if (line starts with "T12 ") { snprintf(new_line, "%s", updated_buffer); modified = 1; } fprintf(tmp_fp, "%s", new_line); } if (modified) rename(tmp_path, data_path());用临时文件加rename,本质上就是借鉴了原子写回的思路。就算程序在中途崩溃,原始文件也基本不会被写坏。这一点在文本数据上尤其值得养成习惯。
3.3 现场演示:从上手到集成
为了让你更直观地感受这个工具怎么用,我把第一次完整试用caveman的终端记录简化了一下。从编译到跑通,一共就这几步:
make make install caveman add "准备周会材料" -P 1 caveman add "整理报销单" caveman add "给书架做一次断舍离" -P 3 caveman listlist的输出如下:
T1 2025-06-14 P1 准备周会材料 T2 2025-06-14 P2 整理报销单 T3 2025-06-14 P3 给书架做一次断舍离然后下午把周会材料搞定,标记完成之后再次查看:
caveman done T1 caveman list输出变成:
T2 2025-06-14 P2 整理报销单 T3 2025-06-14 P3 给书架做一次断舍离到了周末归档,直接执行caveman archive,已经完成的任务会进入~/.caveman/archive.txt,主文件里只剩下未完成的部分。整套流程没有任何多余动作,从想到要做某件事到记下来,三秒内能完成,这就是一个个人工具该有的速度。
3.4 让任务按优先级排序的输出逻辑
list命令并不是按文件里的原始顺序输出的,而是先按优先级 P1、P2、P3 分桶,同优先级再按ID升序。实现上我先把符合open条件的任务塞进一个数组,然后写一个比较函数:
int task_cmp(const void *a, const void *b) { const struct task *ta = a, *tb = b; if (ta->prio != tb->prio) return ta->prio - tb->prio; return ta->id - tb->id; }这个排序规则会直接影响使用习惯:每天早上一打开终端,最先看到的永远是当天最该啃的第一件事,而不是最晚记下来的那件事。done之后任务立刻从列表里消失,视觉上也是一种正反馈。
4. 常见问题与排查技巧实录
4.1 中文任务描述乱码:从源码到终端的一整条链
我自己第一次编译完就撞上了中文乱码。现象很典型:caveman add "准备周会材料"之后,用list看到的中文变成了乱码。排查下来有两个原因。
一个是源码文件本身的编码。如果编辑器把.c文件保存成 GBK 或者 GB18030,GCC 默认又会按 UTF-8 读取源码,字符串常量里的中文从一开始就错了。解决办法是在编辑器里统一存成 UTF-8,同时给编译参数加上-finput-charset=UTF-8 -fexec-charset=UTF-8,正如前面的 Makefile 里那样。
另一个原因是终端环境的 locale。如果 Linux 系统的LANG不是zh_CN.UTF-8,终端可能不会按 UTF-8 解码输出。我用了一个比较通用的处理:在main函数开头调用setlocale(LC_ALL, ""),让程序主动跟随系统环境变量。代码里加上:
#include <locale.h> int main(int argc, char **argv) { setlocale(LC_ALL, ""); // ... }从此之后,我到哪台机器上编译都不会再为中文输出折腾了。
4.2 任务ID为什么不能直接用行号
初版caveman用的就是行号当ID。当时觉得反正也没多少人用,文件行数也不多,行号完全够用。但实际跑了两个星期就发现不对劲。某天我手工用文本编辑器打开tasks.txt删掉了一条写错的旧任务,再跑caveman done T6,结果发现 T6 指到了另一条任务上,因为删除那条旧行之后,后面的行号全部前移了。
改成显式ID之后,这个坑彻底消失。ID一旦分配出去,永远不会给别的任务复用。即使某条任务被delete删掉了,后面新任务拿到的ID也只会更大。这样就允许用户放心大胆地记住一个ID并在之后几天内继续使用。所以如果你要自己写类似工具,我的第一个建议就是:ID一定要持久,不能依赖位置。
4.3 两个终端同时写文件:并发怎么办
caveman正常情况下是单用户、单命令,不太会碰到并发问题。但我在跑自动备份脚本时,确实遇到过caveman add和另一个脚本同时写入导致部分内容错乱的情况。这个问题的原因是两个进程同时以追加模式打开同一个文件,并用fprintf写入时,互相覆盖很容易发生。
解决办法是在写入之前给文件加一把“建议锁”。在 Linux 和 macOS 上,用flock比较简单:
#include <sys/file.h> int fd = open(data_path(), O_RDWR | O_CREAT, 0644); flock(fd, LOCK_EX); // 执行读取-修改-写回操作 flock(fd, LOCK_UN); close(fd);锁虽然是“建议锁”,但只要所有写路径都遵守,就能保证同一时刻只有一个进程改动文件。我在add_task和archive这两个写操作里都套上了这把锁,之后再也没有出现过文件错乱。
4.4 脚本调用与日常集成
用了caveman一段时间之后,我开始想让它在后台自动帮点忙,而不是每次手动敲命令。比如每天早晨在 cron 里固定加一条低优先级的例行任务:
0 9 * * * /home/you/.local/bin/caveman add "检查服务器磁盘空间" -P 3这里有个小坑:cron 默认 PATH 很短,基本不包含~/.local/bin,所以要么用绝对路径调用,要么在脚本里先export PATH="$HOME/.local/bin:$PATH"。另外,cron 环境下没有交互式终端,环境变量CAVEMAN_FILE如果依赖.bashrc设置,需要显式在 cron 脚本里也导出一次。
我还写了一个简单统计命令,用来计算当前未完成任务数:
grep -c '^T.*\[open\]' ~/.caveman/tasks.txt把它绑到 shell 提示符或者 tmux 状态栏里,每天一抬头就能看到积压数量。这种轻量集成方式,比装一个常驻后台服务舒服得多。
4.5 常见问题速查表
| 问题 | 可能原因 | 处理方式 |
|---|---|---|
| 中文乱码 | 源码编码或终端locale不对 | 源码存UTF-8,编译加charset参数,程序里setlocale |
| 任务ID对不上 | 使用行号当ID / 手工删过行 | 改用显式ID,删除任务也保持ID单调递增 |
| 命令找不到 | PATH没有包含安装目录 | 把~/.local/bin加入~/.bashrc |
| 数据文件写乱 | 并发写入同一文件 | 写操作加flock锁,用临时文件+rename替换 |
| 添加任务失败 | 数据目录不存在或权限不对 | 确保~/.caveman存在且当前用户可写 |
| 筛选年轻的任务 | 描述里包含空格导致解析错位 | 用%n定位描述起点,不要用%s读描述 |
5. 实际跑了半年:值得留存的体验
最后聊点工具以外的东西。caveman不是一个完美的程序,它的界面是黑底白字的终端,它的功能少到可能让习惯看板的人觉得寒酸。但半年用下来,我反而觉得这种“原始”是我能一直用下去的核心原因。
第一个体会是,工具一旦被设计成“用完就走”,它就不会反过来消耗你的精力。以前我用那些功能繁重的App,打开一次要处理完小红点、推送、同步状态,才能开始记事情。现在打开终端敲一行caveman add,任务已经进去了,整个过程没有多余提示,也没有值得分心的地方。
第二个体会是,约束越硬,越不容易跑偏。我几次想给caveman加“截止日期提醒”和“标签系统”,最后都被自己按住了。为什么?因为只要加上提醒,我就得为它维护一套时间判断逻辑;只要加上标签,我就得每次添加任务时纠结该打哪个标签。这些恰恰是我最初想逃离的东西。
第三,纯文本虽然笨,但它真的不怕坏。我备份过很多次tasks.txt,也曾经在电脑重装之后直接把它拷回来继续用,没有迁移成本,没有版本兼容问题。数据握在自己手里,心里踏实。
如果你也想动手做类似的个人工具,我不建议一上来就追求完整。可以从一个最离谱的简化版开始,比如只用 shell 脚本加echo >>实现,跑两周真实场景,再根据自己手痒的地方决定要不要升级成 C 语言。工具是给人用的,不是给人供着的。半年来caveman教会我的最重要的事,大概就是:把工具做小,把事情做完。