☰
caveman:一个纯文本命令行笔记工具的设计与实现
2026/10/8 5:18:10 网站建设 项目流程

最近我一直在维护一个叫caveman的小工具。名字听着像考古项目,其实是个特别简单的命令行笔记工具:它没有任何数据库,不依赖任何云服务,甚至连配置文件都懒得写,所有的笔记就是一堆纯文本的.md文件放在同一个目录里。之所以叫 caveman,是因为我一开始就想做点“原始”的东西——只靠文件系统最基本的本事,把记笔记这件事做回最简单、最不容易坏的样子。

做这个项目的起因很实在。这几年各种笔记软件换了一轮又一轮,有的需要登录账号,有的把数据存在私有格式里,有的笔记量过万直接卡死。最让我不能忍的是,工具一旦停止维护,数据导出的折腾程度堪比搬家。于是我决定给自己写一个极简到极致的工具:它只负责三件事,新建一条笔记、列出所有笔记、按关键词搜笔记。剩下的全部交给文件和系统命令去管。如果你也受够了重工具,想找回那种“数据随便复制、随时能读”的踏实感,这篇就讲讲我怎么做出来的,以及过程中踩过的那些坑。

1. Caveman是什么:我为什么用穴居人思维做工具

1.1 名字背后的一种返璞归真

先说名字。caveman直译是“穴居人”,在英文语境里常被用来形容一个人行为方式很原始、很直接。我拿它当项目名,想表达的是工具设计上的三个态度:第一,能用文件名表达的信息,绝不额外建字段;第二,能用系统中已存在的命令做的事,绝不自己写功能;第三,能用文本承载的内容,绝不用二进制格式。这听起来像是技术上的“懒惰”,但实际用久了会发现,这种原始恰恰保证了工具的长寿。

现代笔记工具最大的问题不是功能少,而是功能太多。你刚打开一个新笔记 App,迎面是文件夹、标签、双链、看板、提醒、协同编辑,还没记录任何想法,光是设置结构就花了一小时。caveman反着来,它把整个系统收敛成一句话:笔记就是一个带文件名的文本文件。你叫它什么,它就存在哪个文件里,你写了什么,就是笔记的全部内容。没有二级概念,没有隐藏索引,没有后台数据库。对工具来说,这是一个非常“野蛮”但极其稳定的设计。

1.2 庞大工具带来的真实痛点

我个人的数据迁移经历特别能说明问题。前些年我用过一个流行笔记软件,累计记了好几千条内容。后来想导出成 Markdown,结果发现导出工具只支持一半格式,有很多代码块被转成了图片,部分插件插入的组件直接丢失。折腾一个周末,最后只能手动复制关键内容。也是从那时候起,我开始怀疑一切私有数据格式的工具。数据是我一个字一个字写进去的,凭什么导出来的时候还要看工具脸色。

caveman从根上规避了这个痛点。它存储的就是普通.md文件,你不需要任何专用程序也能读取。你用 VSCode 能打开,用 Notepad 能打开,用cat能打开,甚至用手机自带的文件管理器看前缀预览也能猜个大概。就算哪天这个工具彻底不维护了,对数据也毫无影响——这就是我理解的“原始”带来的安全感。

1.3 边界意识:它刻意不做什么

做工具的人容易犯一个通病:什么都想加进自己的产品里。做caveman时我给自己的第一条禁令就是:不允许加入任何花哨功能。具体来说它刻意不做这几件事:不做云同步,同步交给现有的同步盘或者 Git;不做富文本编辑器,编辑交给 Vim、VSCode 或者其他你顺手的编辑器;不做日历视图,不做标签体系,不做任务状态机。

也许有人会问,那它跟直接建一个记事本文件夹有什么区别?答案是:区别只在入口效率。直接建文件夹当然也能记,但你需要手动起文件名、手动整理目录、手动记住某条笔记当时叫什么叫什么,才能在需要时靠路径找回来。caveman把这些操作变成了几条简短的命令,同时把“当日时间戳+关键词”自动组装为文件名,让你不用在命名上花太多心思。它的价值在于提供了一个顺手的工作流,而不是创造一个新世界。

2. 核心设计拆解:为什么是纯文本加命令行

2.1 三条设计原则,缺一不可

开发过程中,我把设计原则压缩成三条,每天新增代码前都会对照检查:

第一条,所有数据必须是人可读的。无论程序崩溃、硬盘损坏还是换电脑,拿起任何一款文本编辑器都能恢复数据。第二条,工具本身必须是可丢弃的。也就是说,如果我把caveman的脚本删了,完全不记得它的源码逻辑,我也能凭笔记目录里的文件结构反推出当时整理了哪些内容。第三条,系统依赖最小化。能用mkdir、ls、grep解决的事情,绝不引入 Python 包、数据库引擎或网络服务。

这三条原则决定了整个技术架构。目录结构是最原始的:~/.caveman/下一堆.md文件。查阅列表时直接用ls -t按修改时间倒序排列。搜索时直接用grep -rin在目录里做大小写不敏感的全文查找。整个思路其实就是把系统自带的能力拼装一下,而我要写的代码只承担“拼装逻辑”这一层。

2.2 存储方案对比:为什么数据库反而是负担

在做技术选型时,我认真对比过三种方案:SQLite 数据库、单个 JSON 文件、多文件纯文本目录。当时我画了一张很简单的对照表,结论非常明确。

方案优势代价
SQLite查询强大,支持复杂检索数据固化在二进制文件中,备份迁移需要专门工具,笔记无法用文本编辑器直接查看
单个 JSON 文件结构统一,便于解析文件越写越大,写入时容易损坏,同一个文件被多个编辑器打开时冲突严重
多文件纯文本互通性最强,系统命令可用,数据无锁定没有内置复杂查询,需要靠文件命名规范来辅助检索

如果分类只有几十条,JSON 方案其实也够用。但我日常记录量会增长,再加上偶尔要从手机上快速看一眼旧笔记的内容,纯文本散文件的优势就体现出来了:它们可以被系统的文件搜索直接命中,可以被网盘单独同步,也可以被 Git 做精细的版本管理。反过来看,SQLite 虽然查询强,但数据都在一个文件里,同步时永远整库搬移,还要担心并发写入冲突,对轻量笔记来说完全是杀鸡用了牛刀。

2.3 命令行交互:入口越短,记录越勤

工具最终采用命令行的交互方式,而不是做一个 GUI。原因是记笔记这个动作发生的场景,通常是在工作间隙里,脑子里的想法转瞬即逝,越需要快速调用,工具入口摩擦就要越低。GUI 至少要先打开窗口、等界面加载、再找输入框;命令行则是一瞬间的事,终端里敲几个字母就能完成记录。

我把最常用的操作压缩为六条命令:caveman init初始化目录;caveman add "文本"添加一条新笔记;caveman list列出最近的笔记,并带上序号;caveman open 序号用默认编辑器打开指定笔记;caveman search 关键词全文搜索;caveman rm 序号删除指定笔记。这一套命令按使用频率排列,最核心的add和list连参数都尽量简短,让人形成肌肉记忆。命令设计上还有一个细节:所有命令都支持在第二条参数里缺省时走标准输入,方便管道操作。

3. 从零实现Caveman:核心代码与实操记录

3.1 准备阶段:建立目录与环境

开始动手之前先做两件准备:第一件,确定笔记归档的根目录。我建议放在用户目录下建隐藏文件夹,也就是~/.caveman,这样既不会弄乱工作目录,也让文件位置足够明确,后面做同步备份时直接把整个目录扔进同步盘即可。第二件,确定脚本入口。

如果你用的是 Linux 或者 macOS,可以直接把脚本放到/usr/local/bin/caveman并赋予执行权限。Windows 用户可以用 Git Bash 或 WSL 来跑,也可以把脚本放在任意目录后将路径加入PATH环境变量。我自己的机器是 Linux 和 macOS 混用,所以脚本写得尽量符合 POSIX 标准,尽量不依赖 Linux 特有的扩展命令。

初始化逻辑非常简单,就是创建那个目录,同时生成一个 README 文件来提醒自己这个目录的作用:

caveman() { CAVE_DIR="${CAVE_DIR:-$HOME/.caveman}" } caveman_init() { mkdir -p "$CAVE_DIR" && touch "$CAVE_DIR/README.md" && echo "Caveman notes initialized at $CAVE_DIR" }

CAVE_DIR这个环境变量的设计,是为了以后万一想切换笔记目录时不用改脚本,直接换环境变量就行。这种小设计虽然简单,但能避免硬编码路径带来的麻烦。

3.2 Bash版实现:最原始的版本只用了几十行

下面这个版本就是我最早用的脚本,它没有任何第三方依赖,主体逻辑也就是mkdir、cat、ls、grep这几个命令的排列组合。加笔记的时候,脚本会自动生成一个以时间戳加关键词命名的文件:

caveman_add() { local timestamp timestamp=$(date +%Y%m%d_%H%M%S) local title="${1:0:30}" local filename="${timestamp}_${title//[^a-zA-Z0-9_-]/_}.md" if [ -t 0 ]; then echo "$1" > "$CAVE_DIR/$filename" else cat > "$CAVE_DIR/$filename" fi echo "saved: $filename" }

解释一下这段逻辑:date +%Y%m%d_%H%M%S生成一个精确到秒的时间戳,把传入的首个参数截取前 30 个字符作为标题,这里花了点功夫把特殊字符替换成下划线,避免文件名里出现斜杠或者空格导致路径出问题。[ -t 0 ]判断标准输入是否来自终端,如果是来自管道,就直接把管道内容当作笔记正文写入。

列出笔记我用的是:

caveman_list() { ls -t "$CAVE_DIR"/*.md | awk -F/ '{print NR ". " $NF}' }

这个命令把目录下所有.md文件按修改时间从新到旧列出来,awk -F/截取文件名部分展示,并且加上简单的序号。虽然实现得非常朴素,但已经满足了一个基本诉求:一眼看到最近记了哪些东西。

搜索功能更加简单,直接调用系统grep:

caveman_search() { grep -rin "$1" "$CAVE_DIR" }

-r递归目录,-i忽略大小写,-n显示行号。第一次写出来的时候我都愣住了,原来“全文搜索”这个功能只需要一行命令。这也正是caveman想要追求的效果:借用系统已有的能力,而不是重新发明轮子。

3.3 增加序号与打开、删除操作

纯列出来文件名还不太利于操作,于是我在list的基础上关联了序号操作。因为脚本不做状态持久化,我采用了一种简单的顺序映射:每次根据当前时间排序的结果,依次编号,然后open和rm都重新执行一次相同排序,再按序号定位文件。这样做虽然稍微牺牲了一点性能,但对几百条笔记的体量来说绰绰有余,也避免了维护索引的复杂度。

打开操作我直接用$EDITOR环境变量指定的编辑器:

caveman_open() { local idx="$1" local target target=$(ls -t "$CAVE_DIR"/*.md | sed -n "${idx}p") "$EDITOR" "$target" }

删除操作则是定位到文件后执行rm:

caveman_rm() { local idx="$1" local target target=$(ls -t "$CAVE_DIR"/*.md | sed -n "${idx}p") rm "$target" echo "removed: $target" }

这里我踩过一个小坑,sed -n "${idx}p"的引号一定不能丢,否则变量不会展开,命令行会报错。这种细节写的时候很容易忽略,但实际调试起来会让人摸不着头脑。

3.4 迁移到 Python:跨平台与漂亮输出的折中

Bash 版本虽然完全可用,但在 macOS 与 Linux 混用过程中,我还是发现了一些不便:macOS 的date命令默认不支持%N纳秒格式化,偶尔快速连续加笔记时文件名会重名;另外 Bash 版的列出结果没有任何颜色和格式区分,屏幕刷屏后很难快速定位。于是我又用 Python 写了一个兼顾跨平台和可读性的版本。

#!/usr/bin/env python3 import os import sys import subprocess import tempfile from datetime import datetime CAVE_DIR = os.environ.get("CAVE_DIR", os.path.expanduser("~/.caveman")) def ensure_dir(): os.makedirs(CAVE_DIR, exist_ok=True) def note_list(): ensure_dir() files = [] for name in os.listdir(CAVE_DIR): if name.endswith(".md"): full = os.path.join(CAVE_DIR, name) files.append((os.path.getmtime(full), full)) files.sort(reverse=True) return files def cmd_add(args): ensure_dir() if len(args) == 0: content = sys.stdin.read() else: content = " ".join(args) if not content: print("empty content, ignored") return now = datetime.now().strftime("%Y%m%d_%H%M%S") title = content.splitlines()[0][:30] safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in title) filename = f"{now}_{safe}.md" with open(os.path.join(CAVE_DIR, filename), "w", encoding="utf-8") as f: f.write(content + "\n") print(f"saved: {filename}") def cmd_list(args): files = note_list() for i, (_, full) in enumerate(files, 1): name = os.path.basename(full) print(f"{i:3d} {name}") def cmd_search(args): keyword = " ".join(args) for _, full in note_list(): try: with open(full, "r", encoding="utf-8") as f: for line_no, line in enumerate(f, 1): if keyword.lower() in line.lower(): name = os.path.basename(full) print(f"{name}:{line_no}: {line.rstrip()}") except UnicodeDecodeError: continue def cmd_open(args): files = note_list() idx = int(args[0]) - 1 target = files[idx][1] editor = os.environ.get("EDITOR", "vim") subprocess.call([editor, target]) def cmd_rm(args): files = note_list() idx = int(args[0]) - 1 target = files[idx][1] os.remove(target) print(f"removed: {os.path.basename(target)}") def main(): if len(sys.argv) < 2: print("usage: caveman [init|add|list|search|open|rm]") return ensure_dir() cmd = sys.argv[1] args = sys.argv[2:] if cmd == "init": print(CAVE_DIR) elif cmd == "add": cmd_add(args) elif cmd == "list": cmd_list(args) elif cmd == "search": cmd_search(args) elif cmd == "open": cmd_open(args) elif cmd == "rm": cmd_rm(args) if __name__ == "__main__": main()

这个版本的add命令支持两种输入方式。一种是caveman add "直接写入",另一种是通过管道传内容,比如echo "临时想法" | caveman add。核心逻辑在cmd_add里:先判断有没有命令行参数,没有就读取标准输入,这就天然兼容管道操作了。同时,保存时自动补一个换行符,保证后续grep匹配每一行内容时结果干净。

列表输出为了对齐序号,我使用了f"{i:3d}"格式化,这样超过一百条笔记时也能保持阅读舒适。Python 标准库自带的os和subprocess就能完成上述全部功能,依旧保持了“零第三方依赖”的原则。这种克制让脚本在任何安装了 Python3 的机器上都能跑,不用pip install任何东西,也不需要额外的依赖锁文件,和caveman的整体气质一致。

3.5 高级细节:搜索时如何保证编码处理

Python 版搜索中我特意加了一步UnicodeDecodeError处理。刚开始没有这一步时,如果目录里混入一个非 UTF-8 编码的文本文件,整个搜索就会中断,后面所有正常笔记结果都无法显示。后来我改成逐文件读取,遇到解码错误就跳过该文件而不是让程序直接崩溃。这种容错虽然看起来很不起眼,但真实场景里非常管用,因为我偶尔会用scp从旧设备拷贝一些历史文档,编码经常不再是标准 UTF-8。

4. 把Caveman接入日常工作流:这才是关键

4.1 和编辑器配合:让打开编辑变成顺手的事

命令行工具最大的优势之一就是容易和编辑器深度结合。我在日常的编辑器配置里做了一组快捷键,比如在普通模式下按<leader>n,就会自动执行caveman add "临时记录",并把光标预先放在输入框里。这样我在写代码或者看文档时,突然有灵感,不会跳出当前上下文,也不需要打开浏览器或独立的笔记 App,只需要低头敲几个字母就能记录下来。

open指令的意义在这里就体现出来了。当list显示出一串文件名,而我需要补充昨天写的那条笔记时,直接执行caveman open 3就会用$EDITOR打开对应的文件。对我来说$EDITOR指向的是 Vim,但在内网服务器上它也可以轻松指向nano,一切取决于当前环境,工具不强制绑定任何编辑器。

4.2 别名和快捷键:把记笔记变成零成本动作

命令行工具如果每次都要完整敲caveman add,用久了还是会累。我在 shell 配置里加了一个别名,让命令再短一半:

alias c=caveman alias cl='caveman list' alias cs='caveman search'

其实这一步看起来只是减少了一个单词的输入量,但对使用习惯的影响非常大。我测试过,如果一条命令超过 8 个字符,记录动作的启动成本就会显著上升,很多一闪而过的想法就这么丢了。有了c这个别名后,记录一个想法只需要c "xxx",整个过程不到半秒,几乎等同于随手写便利贴。

如果你用的是 macOS,还可以借助 Alfred 或 Raycast 这类工具,把caveman做成一个全局快捷键触发。按下快捷键后弹出输入框,输入文本回车,底层执行的就是caveman add。这样就把终端的门槛也拆掉了,相当于在操作系统的任何界面下都能快速记录。

4.3 同步备份:用现成方案而不是做新方案

很多占了数据锁定便宜的工具,会顺带吹嘘自己“多端同步”做得有多好。但同步这个能力,其实本质就是数据复制,被无数成熟工具解决了,根本不需要笔记工具自己重复实现一遍。caveman因为数据是纯文本散文件,可以直接把~/.caveman目录放进各类同步网盘里,让目录自动同步。你在一台电脑上写下的笔记,几秒后手机上就能看到。

我自己的方案是配合 Git 做版本管理。在~/.caveman目录下初始化 Git 仓库,每次写了一定量的笔记后手动执行一次提交。这样做的好处是,每一版笔记都留下了历史记录,即使手滑写错了一个段落,也可以随时回滚到上一个提交。纯文本文件配合 Git,堪称绝配,因为差分算法对文本格式的支持非常成熟,每次提交的体积都很小。

如果你更愿意走全自动路线,也可以配一个简单的定时任务,每半小时把目录里新增的文件git add并提交一次。但坦率说,笔记不是高频变更数据,手动定期提交反而能激发一次整理和回顾,比全自动多一份好处。

5. 踩过的坑和排查记录:都是实际操作积累的

5.1 中文文件名与 URL 编码问题

最早版本我用标题里的中文直接拼文件名,结果发现部分云同步工具会自动把非 ASCII 字符做编码转换,导致同步后的文件名出现一串百分号乱码,在手机端完全不可读。后来我强制把文件名里的非字母数字字符统一替换成下划线,用日期和数字保证绝对安全。这也带来了另一个好处:文件名在众多系统里都能兼容,不会因为特殊字符导致命令行操作出错。

同时,搜索时如果输入中文关键词,grep本身一般没有问题,但前提是终端和文件编码都是 UTF-8。建议你在脚本开头或者 shell 配置里统一设置LC_ALL=C.UTF-8,避免各种语言环境下的编码混乱。

5.2 参数带空格与连字符导致解析错乱

用命令行工具,最讨厌的场景是参数里有空格和以-开头的敏感字符。比如说caveman add "- 今天心情不错",如果脚本没有做好参数处理,-开头的内容被当成一个选项,直接导致命令执行失败。Python 版本里我用" ".join(args)来拼接参数,可以天然规避这个问题。但如果你在底层调用grep搜索以-开头的内容,还是要记得在关键词前加--表示结束选项解析,这是一个很少被新手注意但实际经常踩中的细节。

5.3 快速连续写入造成的文件名冲突

Bash 版用date +%Y%m%d_%H%M%S生成时间戳,试过在极短时间内连续添加两条笔记,因为秒级精度一样,后一条直接覆盖了前一条。这个问题在 Python 版中依然存在,因为strftime默认也是秒级。解决办法是:要么在文件名后缀加 UUID,要么至少追加计数器。我采取了后者,在同一秒内多次写入时,文件名会变成20250220_103112_1.md、20250220_103112_2.md。简单,且自然保留了记录顺序。

5.4 误删笔记:幸好时间戳救了我

删除操作虽然简单,但误删的代价可不小。有一次我在清理测试笔记时,本来想删掉编号 12 的文件,结果眼睛一花把编号 13 的真实内容删掉了,文件直接没了。好在我的目录用的是 Git 管理,一条git checkout就把历史内容恢复了。如果不用 Git,最稳妥的做法是把删除改成移动:不是真的rm,而是把目标文件移到一个.trash子目录里,隔一个月再彻底清空。这层保护花不了多少代码,却能避免很多心碎时刻。

5.5 搜索时遇到二进制文件干扰

如果目录里混进了图片或者其他二进制文件,grep -r会直接报错,甚至把终端刷出一堆乱码。Python 版里我给搜索函数加上了扩展名白名单,只扫描.md和.txt文件,其他文件一律跳过。更稳妥的做法其实是给笔记目录严格定义一个单一扩展名,拒绝任何来路不明的文件混入。

5.6 多终端同时编辑的冲突

在台式机和笔记本同时开着同一个笔记时,如果两个终端都打开了同一个文件,各自编辑后保存,最后写盘的会覆盖掉先写盘的。这个问题在纯文本方案里无法从工具内部彻底解决,我的办法有两个:一个是尽量避免同一篇笔记在两台机器上同时编辑,另一个是配合同步盘的分身或者 Git 的冲突标记来处理,Git 在文本文件冲突时会在文件里写入冲突标记,至少你能手工合并,不会默默丢数据。

6. 一点个人体会:极简工具维护起来是什么感觉

这个项目前前后后改了近两年,功能点基本稳定,新增代码量反而越来越少了。最大体会是:维持极简并不容易,真正难的从来不是写代码,而是长期拒绝“顺手加功能”的诱惑。不止一次有朋友问我,为什么不做成 App、不上云、不做成多人协作,每次我都要重新把这个项目“不要什么”的边界解释一遍。

但恰恰是这种克制,让caveman异常可靠。我的笔记目录已经有相当多的文件,检索基本还行,日常操作更是毫无压力。最让我踏实的一点是,我完全不需要担心服务商跑路、数据库损坏或者“某天打开软件提示登录失效”。只要我的硬盘还在,这些笔记就在。

如果你也想动手做一个类似的工具,我的建议是:先定好“这个工具将来不要什么”,再开始写第一行代码。每次新想法冒出来,先挪到愿望清单里放两周,如果两周后还觉得需要再加,再认真考虑。很多时候,两周后你就发现自己根本不需要它了。真正经得起时间考验的工具,往往就长这样:功能少得可怜,但每一个功能都耐用到可以依赖终生。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询