最近caveman这个词在我时间线里出现的频率高得有点反常梗图一张接一张但我每次看到它脑子里蹦出来的不是穴居人举着棒子追野兔的画面而是自己在终端里折腾了大半年的那个小项目——代号就叫 caveman。这名字起得多少有点自嘲它没有图形界面不依赖任何第三方库不联网不做花哨的动画只有一个静态编译出来的可执行文件外加一个纯文本数据文件。它要做的事和穴居人的生活哲学差不多——只带最必要的东西把每天最常做的几件事做好别整那些虚头巴脑的。这篇文章算是这个项目的完整复盘为什么非要做这么原始的工具核心技术点怎么拆实际开发中踩了哪些坑以及我最后怎么把它接进日常工作流。如果你喜欢折腾终端或者只是想要一个不给你添乱的个人记录工具下面的内容应该都值得看完。1. 为什么会做出一个原始人从工具焦虑到功能减法1.1 我之前的状态终端里全是没手没脚的工具先说背景。我有个不算好但也不算坏的习惯什么东西都喜欢记一笔。看到一段有用的代码要存临时冒出来的想法要写每天的待办要列买东西的型号和价格也要留个底。为了满足这个需求我前前后后试过不少工具有在线笔记、有本地知识库、有专门todo清单的也有纯文本方案。结果用下来发现一个共同点它们都太重了。在线笔记打开要等加载本地知识库动不动几百MB依赖todo工具打开先弹一个欢迎页。最离谱的是某一次我想在一个刚装好的服务器上跑一个自制的清单脚本发现它要求 Node 18 以上光把环境装齐就花了快二十分钟然后脚本本身也只是往一个 JSON 文件里追加一条文本。那一刻我觉得自己像个笑话需求这么简单为什么我要伺候这么复杂的工具链回头盘点了一下绝大多数个人工具的真实痛点其实是这几个启动速度太慢等它加载完我已经不想写了依赖太多系统一升级就坏数据被锁在某种专有格式里想迁移、备份、grep 都不方便能做的功能太多但我想用的永远只有其中两三个。那时候我就萌生了一个念头有没有一种可能做一个小工具把它压缩到穴居人的程度不带任何包袱打开就能记记完就能查数据文件拖出来就是纯文本这个念头就是 caveman 的起点。1.2 caveman 只做四件事caveman 的名字全称其实是 Cave Mans Ancient Very Efficient Notes有点强行解释但无所谓代号这东西自己懂就行。它的功能被我砍到只剩四个命令命令做什么为什么保留record追加一条记录可以带标签最核心的动作相当于用石斧刻一行字find在全部记录里按关键词搜索记录攒多了总要查这是唯一需要的检索能力dump按时间或标签导出一批内容把原始数据变成能用的输出相当于把猎物拖出山洞wipe清空文件或按条件删掉指定记录虽然很少用但没有它会很别扭这四个命令覆盖了我日常使用频率最高的场景。记一条待办、查一个之前存过的命令、把某一类的笔记导出来给别的程序处理仅此而已。有朋友问过我你连编辑功能都不做吗写错了怎么办我的答案很粗暴写错了就再写一条正确的旧的留着原汁原味反而有脉络。记录这种东西本来就是追加式、不可篡改的更有价值。我甚至刻意把edit排除在外因为一旦允许修改历史记录整个文件的结构复杂度会上一个台阶这个方向与原始相悖。1.3 边界意识哪些功能我故意不做做工具最关键的不是能做什么而是明确不做什么。caveman 的不做清单比做清单更长不做加密。加密意味着密码管理和丢失风险对个人记录工具来说我更愿意把文件权限交给操作系统不做联网同步。同步需要处理网络、认证、冲突合并那是一整套全新问题不做插件系统。插件系统本质上等于埋下一个生态梦最后会把自己拖死不做交互式 TUI。虽然终端界面用起来很酷但它会让程序变大变复杂还得处理各种终端兼容性。这个不做的清单是它真正能保持原始的原因。你把它扔到任何一台 Linux、macOS 或 Windows 机器上只要系统能执行一个二进制文件它就能跑。没有任何环境变量需要设置不需要npm install不需要pip install不需要go mod download。我也清楚它不适合什么人需要团队协作的、需要远程访问的、需要加密存储的人都不应该用 caveman。它就是一个面向单机、个人、文本至上场景的小工具这点从一开始就要想清楚。2. 不装依赖也能跑caveman 的核心实现思路2.1 语言选型为什么我选了 Go定下零依赖、单文件、跨平台的目标之后语言选择其实没有太多悬念。我在 Node、Python、Rust、Go 之间简单列过一个对比语言/运行时单文件分发依赖控制编译/启动速度我的熟悉度Node.js难需要打包器容易引入一大堆启动几十毫秒起步很熟Python难需要 PyInstaller 之类依赖环境易碎启动慢解释器加载耗时很熟Rust容易静态编译容易控制但借用检查器有学习成本编译慢一般Go容易纯静态编译标准库足够编译快启动快很熟选 Go 最直接的理由有三条第一标准库足够覆盖所有需求——文件读写、JSON 解析、字符串处理、时间格式化全是现成的第二静态编译出来就是一个孤零零的可执行文件扔到服务器上就能跑我最在意的那台旧 VPS 上连 glibc 版本都比较老Go 静态编译能省掉一堆兼容性问题第三交叉编译太方便了在一台机器上就能同时打出 Linux、macOS、Windows 三个平台的版本。体积方面我也没吹牛。只用标准库、不引入网络相关包时一个简单的 CLI 在 Linux 上大约 1.5MB用-ldflags-s -w能压到 1MB 出头再用 UPX 压一下可以到 400KB 左右。跟动不动几百MB的 node_modules 比这已经非常原始人了。2.2 数据格式为什么是 JSON Lines 而不是 SQLite存储格式我纠结过几天。最开始想用 SQLite因为查询能力强大但后来发现两个问题一是零依赖纯 Go 的 SQLite 方案要么走 CGO要么用比较重的纯 Go 实现都会让程序体积和复杂度明显上升二是 SQLite 的文件虽然是单文件但对人眼可读这件事极不友好你没法直接在终端里grep它。最后我选择了 JSON Lines也就是 NDJSON每一行是一个独立的 JSON 对象用换行符分隔。比如数据文件长这样{id:7f2a1c9e,time:2025-01-12T09:23:1108:00,tags:[todo],text:给路由器换一个散热风扇} {id:3b88d410,time:2025-01-12T12:01:0508:00,tags:[idea],text:写一篇 caveman 的复盘文章} {id:c1d4e7f2,time:2025-01-12T18:45:2208:00,tags:[code],text:find . -name *.log -mtime 7 -delete}为什么这种格式最合适理由列出来是这么几条追加友好记录操作就是往文件末尾写一行别的什么都不用动逐行解析搜索时只需要按行读、按行解析不需要把整个文件载入内存坏文件容错即使最后一行因为断电只剩半个 JSON前面的记录依然完好最坏情况只是丢掉最近一条人类可读我在服务器上可以直接grep todo data.jsonl完成一次排查不需要任何工具链合并方便不同机器上的文件可以互相追加因为每条记录都有唯一 ID。这个设计在后续使用中被证明非常正确。我最长一次连续用了三个月的数据文件到 7 万多行的时候搜索单关键词仍然能稳定在百毫秒以内完全不需要索引。2.3 核心代码命令分发与追加记录caveman 的代码结构非常简单不搞复杂的架构设计。入口就是一个main.go里面用os.Args[1]做子命令分发package main import ( fmt os ) func main() { if len(os.Args) 2 { usage() os.Exit(2) } var err error switch os.Args[1] { case record: err cmdRecord(os.Args[2:]) case find: err cmdFind(os.Args[2:]) case dump: err cmdDump(os.Args[2:]) case wipe: err cmdWipe(os.Args[2:]) default: usage() os.Exit(2) } if err ! nil { fmt.Fprintln(os.Stderr, error:, err) os.Exit(1) } }record的实现更简单打开数据文件、追加一行完成了func cmdRecord(args []string) error { text : strings.Join(args, ) if text { return fmt.Errorf(record 需要一段文字作为参数) } // 把文件打开方式设成 O_APPEND确保每次写入都追加在末尾 f, err : os.OpenFile(dataFile(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err ! nil { return err } defer f.Close() rec : record{ ID: randomID(), Time: time.Now().Format(time.RFC3339), Tags: parseTags(text), Text: text, } line, _ : json.Marshal(rec) _, err fmt.Fprintf(f, %s\n, line) return err }需要注意的一个小坑是defer f.Close()可能在写入阶段还没执行如果后面报告错误文件会留到函数退出才关闭。所以更严谨的写法是写入之后立刻主动检查错误再Close()并检查关闭错误。实战中还要防止数据库文件所在目录不存在所以cmdRecord开头要补一句os.MkdirAll(filepath.Dir(dataFile()), 0755)。这些都是文档里不会写但实际一定会遇到的问题。2.4 原子写入防止你丢了半个月的笔记如果每个命令都像record那样简单追加肯定没问题。但wipe和未来的任何改写操作就会遇到一个潜在风险如果你直接打开原文件、清空内容、再写入新内容中途断电或进程被杀数据文件就只剩半截。到时候你哭都来不及。我的方案是先写临时文件再原子替换。具体流程是先在同一目录下生成一个隐藏的临时文件比如data.jsonl.tmp把新内容完整写入并调用fsync刷到磁盘最后用os.Rename把它替换成正式文件。这个操作在同一个文件系统内是原子的进程即使中途崩溃最多留下一个临时文件原文件不受影响。func atomicReplace(path string, content []byte) error { tmp : path .tmp f, err : os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err ! nil { return err } if _, err f.Write(content); err ! nil { f.Close() os.Remove(tmp) return err } if err f.Sync(); err ! nil { f.Close() os.Remove(tmp) return err } if err f.Close(); err ! nil { os.Remove(tmp) return err } return os.Rename(tmp, path) }有朋友说追加模式本来就不会损坏原文件为什么要做原子写。这话只说对了一半。追加写确实只会丢最后一条但wipe和dump --rewrite这种涉及重写整个文件的场景时原子写就是最后一道保险。写工具不是写玩具数据安全优先级永远高于一切。3. 真正让我头疼的是这些细节3.1 中文对齐问题一个空格和两个空格之间第一个让我头皮发麻的坑是终端里的对齐。我当时想查询结果嘛几列对整齐不就行了。于是直接用了最简单的办法每列加几个空格。然后在 macOS 的终端里一看全乱了。原因很简单一个英文字符占一列一个中文字符占两列单纯按字符数量填充空格中文多的时候后面的列就会错位。我一开始想自己写一个 Unicode 显示宽度计算函数去查 East Asian Width 表但做到一半发现标准库里没有现成实现引入第三方库又违背零依赖原则。后来我做了个折中不追求严格列对齐只保证时间戳固定宽度、标签前置、正文原样显示。比如这样09:23 [todo] 给路由器换一个散热风扇 12:01 [idea] 写一篇 caveman 的复盘文章 18:45 [code] find . -name *.log -mtime 7 -delete时间戳用固定格式内容直接原样输出不强行对齐后面的列。虽然第一眼不如表格好看但胜在稳定、简单、没有乱码风险。后来我想通了在 CLI 工具里信息清楚比像素级对齐重要得多。3.2 TTY 检测为什么明明是彩色的重定向到文件却变成乱码第二个坑出现在输出上。我一开始为了让结果醒目给标签上了颜色[todo]是红色、[idea]是黄色、[code]是绿色。终端里看确实舒服但重定向到文件或者管道时就惨了caveman dump --tag todo /tmp/todo.txt打开/tmp/todo.txt一看里面全是\x1b[31m这种 ANSI 转义码。原因很简单程序不知道你在终端里还是在管道里只能默认输出颜色。问题的本质是颜色信息对终端有用对文件和管道是噪声。解法其实不复杂就是检测标准输出是否是字符设备。在 Go 里不需要第三方库直接靠os.Stdout.Stat()的ModeCharDevice来判断func outputIsTTY() bool { fi, err : os.Stdout.Stat() if err ! nil { return false } return fi.Mode()os.ModeCharDevice ! 0 }如果输出到了管道或文件就关闭颜色只有终端才开颜色。这个小改动之后caveman ... | grep xxx之类的用法彻底干净了。同样的道理记录条数统计、进度条、交互提示这些人类友好的东西也一律只在检测到 TTY 时输出。3.3 多开进程最后写入的那条记录到底去了哪里第三个坑是最隐蔽的并发写文件。有一次我开了两个终端窗口同时在两个窗口里往 caveman 里加记录。结果发现其中一条丢了文件里没有出现。查下去才明白问题出在我用了缓冲写入。最初版本的cmdRecord用了bufio.NewWriter(f)来写然后defer的时候才触发了写入。但问题在于两个进程同时打开同一个文件都带着自己的缓冲区谁先谁后完全取决于 Flush 的时机。你看着record命令已经返回了其实数据还停在进程的缓冲区里然后整个进程退出了缓冲区来不及刷盘记录就没了。这个坑的教训非常直接不要在不确定性的场景里依赖缓冲。对 caveman 这种单条记录写入的场景直接走fmt.Fprintf(f, ...)不带缓冲性能也完全够因为一天也写不了几万条。如果你真需要批量写入也要保证在进程退出前明确调用Flush()并顺手Sync()绝不能把希望寄托在defer的执行时机上。3.4 时区与国际化为什么备份里的时间总是差八小时第四个坑相对好理解但也容易踩。我一开始为了一看时间就知道是几点存时间用的是本地时间格式比如2025-01-12 09:23:11没有带时区偏移。后来写了一个脚本把数据文件同步到另一台时区不同的机器上一看时间全乱了显示的是源机器的本地时间但读的人以为是自己所在时区的时间理解上就差了八个小时。最后我直接用time.RFC3339格式把时区偏移也写进去。例如2025-01-12T09:23:1108:00 2025-01-12T01:23:11Z存的时候保留完整时间信息显示的时候再按当前机器的时区格式化。这样不管数据将来被复制到哪个时区含义都不会产生歧义。做工具的人如果不做国际化和时间处理问题总会在某个将来跳出来咬你一口。4. 把原始人接进现代工作流日常用法与扩展4.1 最基础的三条 aliascaveman 本身没有任何生态但靠 shell 的 alias 和管道它可以无缝混进现代工作流。我自己的配置文件里就写了这四行alias ncaveman record alias cgrepcaveman find alias clistcaveman dump --last 20 alias cdelcaveman wipe --id日常使用节奏大概是这样的想到一件事立刻n 给老王回邮件确认周五的会议时间想找一个之前存过的命令cgrep rsync晚上想看看今天干了啥clist发现某条记录过期了cdel 7f2a1c9e。这个使用频率远远超过我以前用的任何工具原因就是它快命令直接响应不用等界面加载不用等云同步不用输入一堆参数。我自己统计过从敲下命令到文本真正落盘平均不到 30ms。4.2 与 fzf 配合把查询变成选择题caveman 的find输出是纯文本这给了组合工具极大的发挥空间。我经常这样用caveman dump /tmp/all.jsonl cat /tmp/all.jsonl | fzf --layoutreverse | jq -r .text | pbcopy用 fzf 在全部记录里做交互式模糊选择选中一条之后用 jq 把文字内容抽出来再塞进剪贴板。整个过程只用了三秒不需要打开任何图形界面。这对查历史命令、查旧笔记、找回一个 URL 都特别好用。如果你没有pbcopymacOS 专用Linux 上可以用xclip -selection clipboard替代。关键是 caveman 只负责输出结构化文本剩下怎么用完全交给管道生态。4.3 定时快照与备份因为数据是纯文本diff 都能直接用数据文件是 JSON Lines这让我在备份这件事上占了大便宜。我用 cron 每天定时把数据文件复制到一个带日期的目录15 2 * * * /usr/local/bin/cave-backupcave-backup脚本内容也简单#!/bin/bash set -euo pipefail SRC$HOME/.caveman/data.jsonl DEST$HOME/backups/caveman/$(date %Y%m%d) mkdir -p $DEST cp $SRC $DEST/data.jsonl更妙的是纯文本可以参与 diff。想看看今天和上周的记录差在哪直接diff data.jsonl backups/caveman/$(date -d 7 days ago %Y%m%d)/data.jsonl如果哪天觉得某些记录是误删的把备份文件复制回来就行。这种原始带来的透明感是任何数据库工具都给不了的。4.4 让脚本和本地服务调用它当极简日志收集器用caveman 除了给人用也可以给脚本用。我后来把它发展成了一个极简的本地日志收集器。比如我有个采集天气的 cron 脚本#!/bin/bash temp$(curl -s https://api.example.com/weather?citybeijing | jq -r .current.temp) caveman record --tag metric temp$temp体重记录、跑步里程、每日待办完成数全都可以这样压成一行文本丢进 caveman。等到想看了就caveman find --tag metric | grep ^temp如果没有 caveman脚本们要各自维护一套日志文件路径混乱、格式不一。现在它们只需要写着同一句话把数据丢给 caveman。这比引入一套真正的日志系统轻太多了。4.5 多台机器同步不搞同步功能的同步方案有朋友肯定要问了你数据放在单机上换电脑怎么办我的做法是把数据文件放进 Git 私有仓库或者 Syncthing 里随用随同步。因为每条记录都有唯一 ID而且全部是追加写入即使两台机器各写各的合并时也几乎不会冲突。如果你用 Git 同步注意一点不要用常规的 merge 方式直接以最新版覆盖或者定期合并追加都行因为 caveman 的记录天然是 append-only 且带唯一 ID重复记录在find时会显示两遍但不会破坏任何数据。用dump导出的时候如果想去重可以加sort -u处理一下。这套方案在功能上完全够用同时也守住了我不做同步的边界。5. 复盘后的几点体会项目做了一个多季度现在回头看看最值钱的不是那几百行代码而是几个判断和取舍。第一个体会是少即是多不是口号是实际收益。功能砍到只剩四个命令之后我用它的频率反而比之前用全功能工具高了很多。因为决策成本低了不用考虑这个功能在哪里这个按钮有什么用只有一个石斧拎起来就能砸。第二个体会是数据格式比程序长寿。程序过半年可能重写但那个 JSON Lines 文件永远在永远能读永远能 grep。设计一个新工具先把数据落在别人能打开的格式里当成硬指标比什么架构都重要。第三个体会是测试不能省哪怕工具再小。我吃过一次亏改了一行标签解析逻辑结果所有包含井号的记录都分裂了。后来补了针对 record/find/wipe 三个命令的单元测试对临时目录里的数据文件反复进行读写验证才敢说它稳定。CLI 的测试不难难的是你愿不愿意为小工具写。最后分享一个做同类工具的技巧如果你想给自己做一个类似的小助手别一上来就列功能清单。先把手头最高频的三个动作写下来只做这三个动作等你用了一周再根据真实需求加第四个。你会发现很多想象中必须要有的功能其实一年都用不上一次。caveman 这个项目可能永远都火不起来但对我来说它已经算成功了一个不带赘肉的二进制一块随时能读的纯文本每天陪着我记录那些不重要的、但回头看会微笑的细节。这就是原始人的快乐。