事情是这样的上个月我连续三次在不同项目目录之间来回跳每次都要重新 export 环境变量、切 git 分支、再把 VS Code 重新打开到对应文件夹就在我第四次敲错命令的时候我发现我连“刚才在哪个分支”都需要想五秒。这种状态就是典型的“上下文丢失”——你知道自己在做什么但你的工具不记得于是你的大脑被迫成了缓存。后来我花了两个晚上写了一个非常小的脚本集取名就叫context-mode。它不是重量级平台也不是什么新框架就是一套把“工作现场”保存下来、按需切换的终端工作流。这篇就把它的设计思路、核心实现、踩坑记录全部摊开聊。1. 从一个痛点说起什么叫“上下文丢失”1.1 多次切换后的“我是谁、我在哪”开发者的“上下文”不是玄学它由三个维度的信息构成当前目录、当前环境变量包括项目根目录、API 地址、版本号、当前工具状态比如 tmux 窗口布局、VS Code 打开的文件夹、调试配置。这三个维度一旦切换就需要重新加载而重新加载的过程如果依赖人工记忆十有八九会漏。我用一个具体场景来说明。上午你在维护一个老项目 A老项目用的是 Python 3.8虚拟环境路径是.venv环境变量里还有一个PROJECT_REGIONcn。中午临时切换到新项目 BB 用 Node.js 22需要用 pnpm 启动环境变量NODE_ENVdevelopment而且 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文件用一个入口脚本cmcontext 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 sourcecontexts/old.sh source $old_file type cm_exit /dev/null 21 cm_exit fi fi # 清理上一次可能残留的 CM_ 前缀变量 env | grep ^CM_ | cut -d -f1 | while read -r var; do unset $var done # 激活新上下文 echo $ctx $CM_CURRENT # shellcheck sourcecontexts/new.sh source $ctx_file type cm_enter /dev/null 21 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_REGIONcn export CM_PYTHONpython3 cd $CM_PROJECT_ROOT || exit 1 source $CM_PROJECT_ROOT/.venv/bin/activate # 顺手设置 git 用户信息 git config user.email dev-aexample.com # 通知 tmux 重命名窗口 [[ -n $TMUX ]] tmux rename-window a-backend } cm_exit() { # 退出时自动清掉虚拟环境 type deactivate /dev/null 21 deactivate git config user.email dev-defaultexample.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_enterif [[ -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 编程助手通常都支持“附加上下文”或“项目代理”。但问题是如果你终端里当前上下文是 AAI 助手还停留在 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-aexample.com EOF然后给 AI 助手的自定义指令里加一条“优先读取当前工作目录下的.cm-prompt文件它描述了当前上下文。”这样我切换上下文后AI 助手的参考信息也跟着切换。虽然不能做到百分百同步但至少比纯靠闲聊式提示稳定得多。5. 实测体验与典型场景5.1 一天中的三次切换我拿一个相对典型的远程办公日举例。早上 10 点接了一个线上问题的排查问题在项目 APython 后端。我打开终端敲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 exportgrep 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_NAMEproject-a本地配置文件里覆盖CM_PROJECT_ROOT为本人绝对路径common.sh里做一次合并如果本地设置了CM_PROJECT_ROOT_LOCAL就用它。例如# contexts/project-a.sh export CM_PROJECT_NAMEproject-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 脚本或者直接拿我这份去改。工具本身不重要重要的是把“上下文”当成一个一等公民来管理让它变成你工作流里的一个显式组件。
阅读完成 · 觉得有帮助?