前阵子我手头同时维护着三个项目,一个 Go 写的 API 网关,一个内部后台的前端,还有一个帮朋友跑的定时数据分析任务。每天的节奏基本是:上午看网关日志改队列,下午切到前端调交互,晚上还要回去盯数据脚本。说实话,代码本身的难度不大,真正磨人的是切换状态之后的“找回来”过程。回到项目里,先得回忆上次改到哪、环境变量是什么、项目路径有没有换、临时的备注都写在哪个文件里。就是为这件事,我写了个叫context-mode的命令行小工具,把每个项目的工作环境、路径、变量、待办备注统统打包成一个上下文,想切就切,想回就回。这篇文章就是把这个工具的设计思路、实现细节和踩坑过程完整复盘一遍。如果你也在多项目之间来回折腾,这篇应该对你有用。
1. 上下文管理,到底解决什么问题
1.1 上下文切换的成本
很多人觉得开发效率低是机器性能不够、代码写得慢,但真正被忽略的是上下文切换成本。这玩意儿就像你在厨房同时炖汤、炒菜、蒸鱼,锅盖一掀一盖,火候全乱了。写代码时你脑子里装着的变量名、模块关系、数据结构、当前改到哪一行,这些信息一旦被打断,重新捡起来需要的时间远超你的直觉。
我自己做过一个很简单的测试:连续两个小时只写一个项目,和每二十分钟切换一次项目,同样总共写两个小时,后者的有效产出大概只有前者的六成。不是因为手慢了,而是因为每次切换后都在做同样一件事——回忆。先想刚才改到哪个文件,再想那个文件里的变量叫什么,还想临时记在备忘录里的那行命令是什么意思。
所以我一直在想,能不能把“回忆”这个过程自动化?把每个项目相关的环境信息、路径、待办、甚至临时笔记都存下来,切换项目时一条命令全部加载回来。这就是 context-mode 的初衷。它本质上不是在帮你写代码,而是在帮你保存和恢复工作状态。
你可能会说,Terminal 开多个标签页不就行了?窗口开得多确实能保留界面状态,但你的大脑不会因为标签页开得多就自动记住每个标签页的前因后果。上下文管理的核心是把“脑内状态”显式地落盘,让工具替你记。
1.2 为什么我选择“命令行”来做
做这个工具之前,我也想过要不要做成一个图形界面软件,甚至想过做成 VSCode 插件。后来都否了,原因很简单:命令行工具的可组合性更强。
图形界面最大的问题是信息孤岛。你点开一个面板,看到一堆上下文列表,但你没法把这些信息传给另一个工具。比如你在某个上下文里存了一行备注,你想让 AI 助手启动时自动读取它,GUI 软件要做到这一步就得写一堆胶水代码。命令行不一样,任何信息都是文件,任何文件都能被 grep、被 awk、被 cat,一条命令就能跟你现有的工作流串起来。
我列了个简单的对比表,当时是这样说服自己的:
| 维度 | 命令行工具 | 图形界面工具 |
|---|---|---|
| 组合性 | 高,能跟 shell、编辑器、AI 工具互相调用 | 低,只能在自己的界面内操作 |
| 可版本化 | 配置即文件,可直接纳入 git | 通常存在私有配置里 |
| 自动化 | 天然支持脚本 hook | 需要额外提供 API |
| 上手成本 | 需要记忆命令 | 视觉化,鼠标点击即可 |
另外还有一个实际考虑:我大部分时间都在 Terminal 里待着,工具跟着 Terminal 走才是最顺手的。如果你平时开发也主要靠键盘操作,命令行的体验反而比图形界面更顺。
2. 工具选型与核心存储设计
2.1 用 Python 还是 Shell?
最开始我用纯 Shell 写了一个原型,大概两百行,核心功能就是切换目录、导出几个环境变量。说实话跑起来也还行,但越往后越难受。Shell 处理字符串太痛苦了,特别是项目路径里带空格、环境变量值里带特殊字符的时候,转义转得我头皮发麻。
后来果断换成Python 3,只用标准库,不碰第三方依赖。选择 Python 有几个理由:
- 标准库自带的
argparse、json、os、subprocess足够覆盖全部功能,不需要 pip 安装任何东西; - 字符串处理和 JSON 读写比 Shell 舒服一个数量级;
- 跨平台能力还行,macOS、Linux 都能直接跑,Windows 上的 Git Bash 也能勉强用;
- 后期如果要加功能,比如导出导入、模板复制、自动补全,Python 的生态会让你少掉很多头发。
有人可能会问,为什么不用 Node 或者 Rust?Node 还得装运行时,Rust 编译链太重,我想要的只是一个自己能随时看懂、随时改的本地小工具,Python 的性价比最高。
如果你打算自己写一个类似的工具,我给的建议是:别在一门语言上纠结太久,挑你最熟的、启动成本最低的,先把功能跑通。工具是给自己用的,不是拿来比赛的,能解决问题比技术栈酷不酷重要得多。
目录结构也很简单,整个工具散养在~/.context-mode/下:
~/.context-mode/ ├── ctx # 主命令入口,Python 文件 ├── contexts/ # 上下文数据目录 ├── backup/ # 自动备份目录 └── logs/ # 切换日志2.2 上下文数据格式:JSON 比 SQLite 更合适
存储格式我一开始想过 SQLite,后来还是选了 JSON。为什么?因为这一场景下的数据量实在太小了,每个上下文撑死几十个字段,SQLite 的查询优势完全用不上,反而让数据变得不可读。
JSON 的好处大家都懂:任何文本编辑器都能打开,人能直接看懂,也能直接改;文件之间的差异可以用git diff查看;坏了也好修,改回来看一眼就知道哪里不对。这对一个个人工具来说是极其重要的——我不希望工具挂了之后,我的数据也跟着变成黑盒。
每个上下文的存储结构大概是这样的:
{ "name": "api-gateway", "description": "内部 API 网关项目,Go 编写", "path": "~/work/api-gateway", "env": { "APP_ENV": "dev", "LOG_LEVEL": "debug", "PORT": "8080" }, "aliases": { "gw": "make run", "gw-test": "go test ./..." }, "hooks": { "pre_switch": "echo 'leave api-gateway'", "post_switch": "tmux rename-window gateway" }, "tasks": [ "把队列消费的逻辑从轮询改成推送", "确认一下旧版接口兼容性" ], "notes": "建了 feature/queue-push 分支,进度在 60% 左右", "tags": ["go", "backend"], "updated_at": "2025-03-10 14:32:00" }字段设计的时候我反复斟酌过两个点。
第一个是hooks。这是这个工具的灵魂。切换上下文不是光改几个环境变量就完了,套用一句俗话:进入状态要触发动作。比如我进入前端项目时,自动把 tmux 的窗口名改成frontend,顺便打开项目的 dev server;离开后端项目时,自动执行git status打印当前状态,防止忘掉手头的工作。hooks 就是干这个的。
第二个是tasks和notes。这俩字段看起来简单,实际上非常救命。我经常出现的情况是:周末加班改项目 A,周一回来全忘了。现在我会在每天结束时写两三句 tasks 和 notes,第二天回来ctx show一看,上一天的工作内容立刻亲切起来。
为什么不用全局状态文件?也是深思熟虑的。全局状态文件里放一个current_context字段,看起来省事,但会有并发问题——两个终端同时切换就串台了。我的做法是让每个 shell 自己维护当前上下文名,写入一个.ctx_current环境变量,不搞全局唯一状态。
3. 实操:从零跑通 context-mode
3.1 安装与初始化
这个工具我没发到 PyPI,因为还在自己折腾的阶段,装起来也很简单。两行命令的事:
git clone https://github.com/yourname/context-mode.git ~/.context-mode echo 'export PATH="$PATH:~/.context-mode"' >> ~/.zshrc source ~/.zshrc前提是你的机器上有 Python 3.6 以上版本。装完先验证一下:
ctx --help能看到帮助信息就说明成功了。接着初始化数据目录:
ctx init这个命令会帮你创建contexts/、backup/、logs/三个目录,同时生成一个空的默认配置文件。第一次跑的时候输出大概是这样的:
[context-mode] initialized at ~/.context-mode [context-mode] created contexts/ [context-mode] created backup/ [context-mode] created logs/ [context-mode] all set, use 'ctx add <name>' to create your first context如果你机器上装了 multiple python 版本,可能需要用python3而不是python来运行,我在脚本里顺手做了处理,会自动检测可用的解释器。
3.2 常用命令与真实切换案例
安装完了,最关键的就是实际操作。我先说下整个工作流长什么样。
假设我要新加一个项目上下文:
ctx add blog --path ~/work/blog --env NODE_ENV=development PORT=3000这条命令会在contexts/下生成一个blog.json文件,并且把--env后面的键值对写进env字段。如果你愿意,也可以不加--path,手动编辑 JSON 文件来设置更复杂的 hooks 和 tasks。
装好之后,每天开始工作时的动作就变成了:
ctx use blog执行ctx use之后,工具会读blog.json,做这几件事:
- 如果存在
pre_switchhook,先执行它; - 把
path对应的目录打印出来,让你知道接下来要往哪走; - 把
env里的变量导出到当前 shell; - 把
aliases注册到当前 shell; - 执行
post_switchhook,比如改 tmux 窗口名、打开 dev server; - 最后打印
tasks和notes,把当前状态一次性搬到你眼前。
实际运行效果是这样:
$ ctx use blog [context-mode] now switching to: blog [context-mode] pre-hook: echo 'leaving previous context' ... [context-mode] path: ~/work/blog [context-mode] exported 2 env var(s): NODE_ENV, PORT [context-mode] defined 2 alias(es): dev, build [context-mode] post-hook: tmux rename-window blog [context-mode] ------------------------------ [context-mode] tasks: - 写完 context-mode 的 README - 把 hooks 改成支持数组形式 [context-mode] notes: 遇到 tmux 联动问题,明天继续查每次切换,我都感觉像是把一块记忆卡从脑子里抽出来,换成了另一块。
查看所有上下文列表:
ctx list按名称、路径、更新时间和标签列出来,一目了然。想详细看某个上下文的内容:
ctx show blog这个命令会把blog.json的内容以格式化后的形式打印出来。个人经验是,每天晚上收工之前跑一下ctx show然后顺手改一下 notes,第二天开始工作会无比轻松。
3.3 和终端、编辑器、AI 工具联动
单独跑起来只是第一步,真正让它发挥威力的地方是跟其他工具联动。我踩过不少坑,最后总结出三个比较实用的联动方案。
第一个是 shell 集成。因为ctx use是在子进程里执行的,单纯跑命令没法影响父 shell 的环境变量。解决方案是在.zshrc或者.bashrc里包一层 shell 函数:
ctx-use() { eval "$(ctx use "$1")" }然后就可以在终端里用ctx-use blog来切换,环境变量、别名、路径全部生效。这里的关键是ctx命令本身要输出可被eval的脚本,而不是直接修改环境。我用的是把导出语句打印到 stdout,再由函数吞进去执行。
第二个是编辑器联动。我用 Neovim,在init.lua里加了一小段:
local function load_ctx_notes() local ctx_file = vim.fn.system('ctx current --json') -- 解析 JSON 并展示 notes 字段 end这样一来,每次打开 Vim 都会自动读取当前上下文的 notes,通过一个 float window 显示在屏幕侧边。写代码写到一半想查“我刚才记了什么”,不用切回终端了。VSCode 用户也可以用类似思路,写个简单 extension 或者用 Task 命令。
第三个是 AI 工具联动。现在本地跑 AI 辅助编程很常见,我发现把context-mode里的 notes、tasks 和目录结构导入模型的初始 context,效果会好很多。比如用 OpenAI 的 API 时需要把关于当前项目的背景信息一起发给模型,以前要手动复制粘贴,现在一条命令就能把 JSON 转成 markdown:
ctx export blog --as-markdown然后把输出喂给模型,它就能在上下文里“知道”你正在改哪个项目、上次卡在哪个问题上。这个用法对写技术方案、写提交信息的场景格外好使。
4. 常见问题与排查技巧实录
4.1 环境变量不生效
这个问题几乎每个第一次用的人都会遇到,我自己也没躲过。现象是终端里执行ctx use xxx之后,echo $PORT明明有输出,但脚本一退出,外面再echo $PORT就空了。
原因很简单:你在子进程里设置了环境变量,不会影响父进程。命令行工具跑完就退出,它的所有环境变量随之消亡。
我当时排查的路径是:先怀疑export语句写错了,再加set -x调试,后来才发现问题根本不在导出,而在父子进程。解决方式就是上文提到的 shell 函数包装,通过eval让导出语句在父 shell 里执行。
注意:如果你也在做类似工具,一定要让use子命令输出的是export KEY=VALUE这种可直接执行的脚本,而不是自己试图去修改进程环境。把“输出”和“执行”解耦,才是能配合eval的正确姿势。
4.2 上下文文件损坏
JSON 文件被手动编辑时很容易出问题,最常见的是少了一个逗号,或者引号没闭合。我第一版没有任何校验逻辑,一读文件就抛JSONDecodeError,报错信息还贼难看。
后来加了两个改进。第一个是自动备份:每次ctx use成功之前,先把原文件复制到backup/目录,带上时间戳。第二个是给ctx show和ctx use都加了异常捕获,出错时直接打印提示:
[context-mode] error: failed to parse context file 'blog.json' [context-mode] tip: use 'python3 -m json.tool blog.json' to locate the issue如果真遇到文件损坏,不用慌,用python -m json.tool定位到具体行列,把缺的符号补上就行。如果懒得修,直接从备份目录里恢复:
cp ~/.context-mode/backup/blog.json.20250310-2230 ~/.context-mode/contexts/blog.json我后来学乖了,不太直接手写这种复杂 JSON,而是用ctx task add、ctx note set这类子命令来间接修改数据,出问题的概率小很多。
4.3 多终端不同步
假设你在终端 A 里切换到 project-a,又在终端 B 里切换到 project-b,工具本身不会帮你同步状态。这未必是坏事——有时候我确实需要在两个终端分别处理两个项目,互不干扰。但有网友问过我一个问题:如果我的机器有两个窗口,我想让它们共享同一个上下文,有没有办法?
我的做法是把contexts/目录放到一个本地网盘目录下,或者干脆把它做成 git 仓库。每次切换完自动 commit,这样两个终端读写同一个文件,虽然偶发冲突,但只要你的操作频率不极端,体验基本是顺滑的。
注意容量问题:contexts 目录很小,撑死几百 KB,丢到 git 里毫无压力。但不要把 hooks 里生成的日志也一起塞进去,否则 commit 会很频繁。我在.gitignore里把logs/目录排除掉了。
4.4 踩过的坑与建议
第一个坑是路径写成了绝对路径。绝对路径在本地用没问题,但如果你把.context-mode整个目录拷到另一台机器上,路径大概率是废的。现在我都建议用~相对路径,或者存项目名然后靠别名解析。
第二个坑是把密码、密钥直接存进了 env 字段。一开始我只图方便,后来发现 JSON 文件太容易被各种工具读取,而且如果你不小心把它推到远程 git 仓库,那就真的是安全事故了。现在我只存变量名,真正的值放在.env文件里,而.env被.gitignore排除。
第三个坑与 hooks 有关。hooks 命令如果太复杂,反而成了负担。我第一版设计 hooks 时,写了一个能在切换项目时自动检查 git 状态的脚本,结果每次切项目都慢半拍,后来把脚本精简成一句话。价值主张应该是“快”,而不是“全”。hook 只放最常用、最轻量的动作,其他一律不做。
还有一个经验,如果你也要做 hook,一定要把pre_switch和post_switch分清楚。我一开始只有一个 hook,结果发现切换前的动作和切换后的动作总是混在一起。后来拆开,语义清晰了很多,也更容易调试了。
最后就是别把上下文设计得太“满”。我看有人写类似的工具,每个 context 里存了十几个字段,光看列表都头晕。我的建议是先用最精简的 schema,跑一周,哪些字段用得频繁就留下,用不上的删掉。工具是自己的,越贴合自己的工作习惯越好,千万别为了功能多而堆配置。
我自己现在用的 context-mode,功能其实不复杂,但真正帮我省下的不是那几秒执行时间,而是每天无数次“我刚刚在做什么来着”的卡顿。那种卡顿,只有自己经历过才知道有多难受。