用C语言写一个命令行待办工具:caveman的设计与实践

发布时间:2026/10/28 21:34:19
用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] 修复登录页闪退

每个字段的含义我都写死在解析逻辑里,顺序不能乱:

字段示例说明
任务IDT12全局唯一,单调递增,删除后不复用
状态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 T4

archive是维护主清单清爽度的关键命令。它会扫描所有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 list

list的输出如下:

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教会我的最重要的事,大概就是:把工具做小,把事情做完。