事情是这样的:上个月我连续三次在不同项目目录之间来回跳,每次都要重新 export 环境变量、切 git 分支、再把 VS Code 重新打开到对应文件夹,就在我第四次敲错命令的时候,我发现我连“刚才在哪个分支”都需要想五秒。
这种状态就是典型的“上下文丢失”——你知道自己在做什么,但你的工具不记得,于是你的大脑被迫成了缓存。后来我花了两个晚上写了一个非常小的脚本集,取名就叫context-mode。它不是重量级平台,也不是什么新框架,就是一套把“工作现场”保存下来、按需切换的终端工作流。这篇就把它的设计思路、核心实现、踩坑记录全部摊开聊。
1. 从一个痛点说起:什么叫“上下文丢失”
1.1 多次切换后的“我是谁、我在哪”
开发者的“上下文”不是玄学,它由三个维度的信息构成:当前目录、当前环境变量(包括项目根目录、API 地址、版本号)、当前工具状态(比如 tmux 窗口布局、VS Code 打开的文件夹、调试配置)。这三个维度一旦切换,就需要重新加载,而重新加载的过程如果依赖人工记忆,十有八九会漏。
我用一个具体场景来说明。上午你在维护一个老项目 A,老项目用的是 Python 3.8,虚拟环境路径是.venv,环境变量里还有一个PROJECT_REGION=cn。中午临时切换到新项目 B,B 用 Node.js 22,需要用 pnpm 启动,环境变量NODE_ENV=development,而且 B 的代码风格要求所有 shell 命令前面必须带npx才能跑。
如果你只是简单地cd /path/to/B,然后敲pnpm dev,那当然没问题。但问题在于,项目 A 的终端里可能还留着PYTHONPATH=/app/src这样的导出,你忘了清除,等再切回 A 时发现 import 全崩了。这种“脏上下文”残留,就是 bug 的温床。
1.2 Context Mode 本质上要解决什么问题
context-mode解决的核心问题有四个:
- 把“当前工作上下文”抽象成一份可命名的配置,比如
cm use backend、cm use frontend; - 切换时自动执行进入脚本(enter hook),把环境变量、路径、工具版本一次性配置好;
- 离开时自动执行退出脚本(exit hook),把上一个上下文里导出的变量统一清干净,避免污染;
- 把工具状态也纳入上下文切面,比如让 tmux 窗口自动重命名、让 VS Code 自动打开对应文件夹。
这套思路类似 Docker context 或者 Conda 环境,但它的定位更轻——不绑定运行时,只绑定你“干活时的现场”。你把每个项目的现场存下来,之后切换就像按遥控器一样,一个命令全部到位。
2. 方案选型:为什么不用现成的工具
2.1 几类现成方案的对比
在动笔之前我也犹豫了很久,毕竟市面上已经有不少类似的东西:direnv可以按目录自动加载环境变量,tmuxinator能恢复指定 tmux 布局,devbox、asdf可以管理工具版本,还有一堆.env文件规范。那为什么我还要自己写context-mode?
我列过一个表:
| 工具 | 解决维度 | 短板 |
|---|---|---|
| direnv | 环境变量自动加载 | 只能按目录关联,没法定义“进入/离开”动作,也不管编辑器 |
| tmuxinator | 终端布局恢复 | 只管终端,环境变量和 AI 辅助工具的上下文不归它管 |
| devbox / asdf | 运行时版本 | 只锁定依赖版本,不处理项目管理、环境变量 |
| Docker context | 远端运行环境 | 面向容器和集群,跟本地开发工作流隔了一层 |
| VS Code Workspace | 编辑器工作区 | 只能在 IDE 内切换,终端里还是得手动处理 |
你会发现,每个工具都只解决了上下文的一个切面。真实开发里我需要的是“一个命令把所有切面一起翻转”,而不是手动去调三四个工具。
2.2 我想要的 context-mode 长什么样
我把需求收敛成一条原则:上下文是一个状态机,切换就是重置状态。
于是设计出来了:
- 配置文件放在
~/.context-mode/contexts/,每个上下文一个.sh文件; - 用一个入口脚本
cm(context mode 的缩写)来操作状态机; - 状态记录在
~/.context-mode/current文件里,切换时读取老状态、执行退出钩子、写入新状态、执行进入钩子; - 所有上下文内导出的变量,统一用一个前缀,比如
CM_开头,这样清理时不会误伤系统变量。
这个设计最大的优势是透明:所有逻辑写在 shell 里,你可以直接cat每一个配置文件,不用理解任何专有格式。对于团队协作来说,新人上来读两个文件就能明白全貌,比引入一套复杂的云同步方案靠谱得多。
3. 核心实现:一个位于 ~/.context-mode 的轻量脚本
3.1 文件结构与基础配置
先看目录结构,非常简单:
~/.context-mode/ ├── cm # 主入口脚本 ├── current # 当前激活的上下文名 └── contexts/ ├── project-a.sh # 项目 A 的上下文配置 ├── project-b.sh # 项目 B 的上下文配置 └── common.sh # 所有上下文共享的基础配置(可选)cm脚本的核心逻辑只有三个子命令:list、use、show。我写的时候刻意追求没有第三方依赖,用纯 Bash 实现,这样在任何 Linux、macOS 上都能跑,Windows 用户可以用 WSL 或者 Git Bash 跑。
3.2 主命令:cm 的入口逻辑
主入口脚本我截取最关键的部分:
#!/usr/bin/env bash CM_HOME="${CM_HOME:-$HOME/.context-mode}" CM_CURRENT="$CM_HOME/current" cm_use() { local ctx="$1" local ctx_file="$CM_HOME/contexts/$ctx.sh" [[ -f "$ctx_file" ]] || { echo "context [$ctx] not found"; exit 1; } # 如果当前已经有激活的上下文,先执行它的 exit hook if [[ -f "$CM_CURRENT" ]]; then local old_ctx old_ctx=$(cat "$CM_CURRENT") local old_file="$CM_HOME/contexts/$old_ctx.sh" if [[ -f "$old_file" ]]; then # shellcheck source=contexts/old.sh source "$old_file" type cm_exit >/dev/null 2>&1 && cm_exit fi fi # 清理上一次可能残留的 CM_ 前缀变量 env | grep '^CM_' | cut -d= -f1 | while read -r var; do unset "$var" done # 激活新上下文 echo "$ctx" > "$CM_CURRENT" # shellcheck source=contexts/new.sh source "$ctx_file" type cm_enter >/dev/null 2>&1 && cm_enter echo "switched to context: $ctx" } cm_list() { for f in "$CM_HOME"/contexts/*.sh; do basename "$f" .sh done } cm_show() { echo "current: $(cat "$CM_CURRENT")" env | grep '^CM_' || true } case "$1" in use) cm_use "$2" ;; list) cm_list ;; show) cm_show ;; *) echo "usage: cm [use|list|show]" ;; esac这里有几个细节要说明。每次切换先执行旧上下文的cm_exit,再清CM_前缀变量,最后加载新上下文并执行cm_enter。这样的顺序保证了退出钩子依然能读取到旧上下文里追加的环境变量,否则一旦先清空变量,退出钩子里的收尾逻辑就会缺依赖。
清理变量用的是env | grep '^CM_'然后unset。有些人可能会问,为什么不直接用一个子 shell 来避免污染?原因是你需要切换后的状态在当前终端里持续生效,必须对当前 shell 执行source,所以只能用“记录清单 + 清理”的方式,而不能在子进程里做完再退出。
3.3 上下文定义文件怎么写
每个上下文文件里至少需要定义两个函数:cm_enter和cm_exit。如果不需要做收尾清理,cm_exit可以不定义,因为入口脚本里做了类型检测。
给大家展示一个典型的 Python 项目上下文:
#!/usr/bin/env bash # context: project-a backend cm_enter() { export CM_PROJECT_ROOT="$HOME/work/project-a" export CM_REGION="cn" export CM_PYTHON="python3" cd "$CM_PROJECT_ROOT" || exit 1 source "$CM_PROJECT_ROOT/.venv/bin/activate" # 顺手设置 git 用户信息 git config user.email "dev-a@example.com" # 通知 tmux 重命名窗口 [[ -n "$TMUX" ]] && tmux rename-window "a-backend" } cm_exit() { # 退出时自动清掉虚拟环境 type deactivate >/dev/null 2>&1 && deactivate git config user.email "dev-default@example.com" }写cm_enter时有几个坑我后面会细说,这里先提醒一个:不要在上下文文件里直接cd之后不检查返回值,如果目录不存在,你可能会落在错误的目录里继续干活。所以我在示例里加了|| exit 1,保证路径无效时马上暴露。
4. 把 context-mode 接进编辑器与 AI 辅助工具
4.1 与 VS Code 工作区的联动
终端环境变量切换好了,还不够。我在实际使用中发现,如果 VS Code 还停留在上一个项目的文件夹,那“上下文断开”依然存在。所以我给 VS Code 侧写了一个小扩展逻辑:不用开发完整的插件,就直接用命令行重载窗口。
在cm_enter里加一行:
code --reload-window "$CM_PROJECT_ROOT"它会把当前 VS Code 窗口切换到项目 A 的目录,并重新加载工作区。如果你不想每次都重开,可以判断当前目录和项目目录是否一致,只有不一致时才执行。
if [[ "$(pwd)" != "$CM_PROJECT_ROOT" ]]; then code --reload-window "$CM_PROJECT_ROOT" fi这个方案有一个连带的好处:VS Code 打开的文件夹会带动.vscode/settings.json、.vscode/launch.json等配置一起切换,等于把调试断点、插件默认值也一起换了。
4.2 把上下文带入终端与 tmux 布局
我平时是用 tmux 的,每个项目单独开一个窗口。手动tmux new-window -c $CM_PROJECT_ROOT其实挺繁琐,所以我把它包装进cm_enter:
if [[ -n "$TMUX" ]]; then tmux set-environment -g CM_ROOT "$CM_PROJECT_ROOT" tmux rename-window "$(basename "$CM_PROJECT_ROOT")" fitmux set-environment -g的好处是,后续从这个 tmux 会话里新建的窗格都能继承CM_ROOT环境变量,新开的 shell 里cm_show也能随时看到当前上下文。另外,我还会把每台机器特有的路径配置拆到独立的local.sh里,通过给cm增加一个local参数来加载,避免把绝对路径写死在共享配置里,这样配置文件可以直接放进 Git 仓库。
4.3 让 AI 编程助手也“知道”当前上下文
今年大家用得最多的 AI 编程助手,通常都支持“附加上下文”或“项目代理”。但问题是,如果你终端里当前上下文是 A,AI 助手还停留在 B 的记忆里,它给出的建议就会牛头不对马嘴。
我的做法是在cm_enter里生成一个.cm-prompt文件,里面用文本描述当前项目信息:
cat > "$CM_PROJECT_ROOT/.cm-prompt" <<EOF 当前项目:$CM_PROJECT_ROOT 技术栈:Python 3.8 + Django 环境变量:REGION=$CM_REGION 依赖:source .venv/bin/activate 用户:dev-a@example.com EOF然后给 AI 助手的自定义指令里加一条:“优先读取当前工作目录下的.cm-prompt文件,它描述了当前上下文。”这样我切换上下文后,AI 助手的参考信息也跟着切换。虽然不能做到百分百同步,但至少比纯靠闲聊式提示稳定得多。
5. 实测体验与典型场景
5.1 一天中的三次切换
我拿一个相对典型的远程办公日举例。早上 10 点,接了一个线上问题的排查,问题在项目 A(Python 后端)。我打开终端敲:
cm use project-a脚本自动激活 Python 虚拟环境、进入项目目录、把 VS Code 窗口切过去、git 切到当前迭代分支。整个过程两秒内完成。处理完问题后,我切回文档工作:
cm use docs这个上下文只做了三件事:cd ~/notes,编辑REMOTE_NOTES_ROOT变量,把 tmux 窗口改成docs。中午休息回来,我要继续项目 B(前端),再敲:
cm use project-b这里有个细节:切到 project-b 时,project-a 的cm_exit会自动运行,把.venv/bin/activate释放掉,同时清理CM_PROJECT_ROOT、CM_REGION相关变量。这样我echo $CM_REGION时只会得到空串,不会默认继承 A 的配置。
一天下来,我统计过,完全不需要手动export任何变量,也没出现过“明明切了项目但 shell 里还是旧环境变量”的问题。
5.2 常用命令速查表
为了方便团队使用,我在 README 里放了一张速查表:
| 命令 | 作用 |
|---|---|
cm list | 列出所有可用上下文 |
cm use project-a | 切换到指定上下文 |
cm use - | 切换回上一个上下文(类似cd -) |
cm show | 显示当前上下文名及CM_前缀环境变量 |
cm edit project-a | 编辑指定上下文配置文件 |
| `cm export | grep CM_` |
其中cm use -是我后来加的,原理就是切换前把旧上下文名记录到CM_PREVIOUS里,切换时再互换。这个命令在“两个项目反复横跳”的场景下特别好用。
6. 避坑指南与排错实录
6.1 PATH 变量被覆盖的问题
第一次写上下文脚本时,我直接在cm_enter里export PATH="$CM_PROJECT_ROOT/.venv/bin:$PATH",看起来没问题,但切回默认上下文时,这条 PATH 不会被自动还原。于是我的 PATH 越来越长,早期配的目录被挤到了很后面,一些系统命令出现“有两个版本但调用了错的那个”的诡异现象。
我的解决策略是:不要在cm_enter里往 PATH 头部追加,而是存一份原始 PATH 快照:
export CM_ORIGINAL_PATH="$PATH"然后所有需要改 PATH 的上下文,在cm_exit里执行:
export PATH="$CM_ORIGINAL_PATH"这样就不会重复叠加。另外,公共的common.sh里统一管理 PATH 的追加,避免每个上下文各自 append 导致冲突。
6.2 嵌套切换时的“恢复”陷阱
有同事用我的脚本,在一个上下文里又调用了另一个上下文,结果上下文 B 激活以后,上下文 A 的cm_exit还没跑,B 又覆盖了CM_PROJECT_ROOT。等退出 B 时,A 的环境已经被污染了。
这本质上是把cm use模型设计成了“单上下文模式”,不允许嵌套状态。我在文档里明确写了:一个终端只有一个活跃上下文。如果你需要在同一个终端里再开一个独立环境,正确做法是新建一个 tmux 窗口或者开一个新 shell,然后在那个 shell 里cm use另一个上下文,而不是在当前 shell 里嵌套。
如果实在需要“上下文栈”,可以在cm脚本里维护一个STACK数组,切走时压栈、切回时弹栈,但这会让脚本复杂度上升不少,我目前没有纳入核心版本。
6.3 共享上下文 vs 独立上下文
团队使用中容易踩的另一个坑:多人共用一个上下文文件,但每个人的本地路径不一样。比如你的project-a在/home/you/work/project-a,同事的在/home/colleague/dev/foo,如果你俩共享同一个.sh文件,要么冲突,要么你被迫使用同事的目录结构。
我更推荐的做法是采用“模板 + 覆盖”模式:
- 共享配置文件只写相对路径,比如
export CM_PROJECT_NAME="project-a"; - 本地配置文件里覆盖
CM_PROJECT_ROOT为本人绝对路径; common.sh里做一次合并:如果本地设置了CM_PROJECT_ROOT_LOCAL,就用它。
例如:
# contexts/project-a.sh export CM_PROJECT_NAME="project-a" export CM_PROJECT_ROOT="${CM_PROJECT_ROOT_LOCAL:-$HOME/work/project-a}"这样共享仓库里存的是模板,每个人在自己的~/.context-mode/local.sh里定义CM_PROJECT_ROOT_LOCAL,就避免了互相干扰。
6.4 调试技巧:让你看到每次切换到底改了什么
最后分享一个调试技巧:在cm主脚本的use分支里,加一个CM_VERBOSE环境变量开关。当它被设置为 1 时,切换前后分别输出当前CM_前缀变量的快照,这样你就能直观看到哪些变量被添加、被修改、被清除了。
if [[ "${CM_VERBOSE:-0}" == "1" ]]; then echo "--- before ---" env | grep '^CM_' || true echo "--- after ---" env | grep '^CM_' || true fi排查问题的时候,我通常会在切换失败前echo一下旧上下文的变量列表,确认不是上一轮残留导致的。这个小开关帮我省了很多时间去猜“是不是哪个变量没清干净”。
7. 一点个人体会
context-mode不是一个石破天惊的创新,它更像是一个“把小事做好”的样板。真正让我坚持用下去的原因,是它把“切换项目的仪式感”降到了零。以前我为了切换上下文可能要敲十几行命令,现在一个cm use xxx就完成,而且不会漏掉某个环节。
如果你也想自己搭一套,我给的最重要的建议是:先把你最常倒腾的两个项目做成上下文,跑通流程,再考虑加更多项目。不要一上来就规划一个庞大的环境管理框架,那样只会让脚本越来越重,最后连维护它的动力都没有。
我后来又把context-mode接进了 CI 脚本里:每个项目的构建流程开始前先cm use对应上下文,确保构建机器和本地开发环境读到的变量完全一致。这算是一个意外的收获,但思路也简单:既然切换上下文这么顺手,那让机器也按同样的模式来切换,自然少了很多“本地跑得好、CI 上挂了”的问题。
如果你也有类似“切换项目时上下文丢三落四”的痛点,不妨照着这个思路写一个几十行的 shell 脚本,或者直接拿我这份去改。工具本身不重要,重要的是把“上下文”当成一个一等公民来管理,让它变成你工作流里的一个显式组件。