ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自制命令行工具:用 context 状态机实现工作环境一键切换

自制命令行工具:用 context 状态机实现工作环境一键切换

我最近给自己写了个命令行小工具,叫 context-mode,专门用来一键切换电脑的“工作状态”。写代码时习惯开着 VS Code、iTerm 和几个项目文档,写方案时只需要浏览器和编辑器,休息时希望所有工作软件一个都不出现。以前在这几种状态之间切换,手动开一批、退一批、再调整系统设置,少说也得几分钟,还经常切到一半被消息打断,整个人都烦躁起来。

这个工具把“工作状态”抽象成一个个可以切换的上下文:每个 context 绑定一组应用、一组系统设置、默认工作目录,以及进入/退出时执行的动作。切换时只需要敲一条命令,自动完成“退出旧环境、进入新环境”的整套动作。项目已经在我电脑上跑了两个多月,实测切换耗时从原来的几分钟压到了 5 秒以内。

这篇文章把项目从设计到落地的完整过程拆给你看,包括配置格式怎么选、切换逻辑怎么写、快捷键怎么绑,以及我踩过的几个坑。适合想折腾电脑效率的人,也适合刚开始写命令行小工具、想了解个人项目从 0 到 1 怎么落地的朋友。

1. 设计思路:把工作状态变成可切换的上下文

1.1 我在什么场景下决定做这个工具

先说说背景。我每天在三种角色之间切:写代码、写方案、休息。以前每次切换,动作是固定的:手动开三个应用,手动关五个应用,系统通知从“允许弹窗”改成“勿扰”,再手动 cd 到对应项目目录。如果切到一半有消息打扰,整套流程又要推倒重来。

这种手动切换的本质问题,是“上下文切换”本身很贵。操作系统切换进程上下文要保存寄存器、刷新缓存,代价是时钟周期;人切换工作状态要重新找回思路、重开应用、重置环境,代价是注意力和耐心。每找一次图标、每点一次设置,都是一次短时记忆的浪费。多切换几次,人就烦躁了,操作反而更乱、更容易出错。

我需要一个工具,把“进入某种工作状态”变成一次单一指令。它不需要多智能,只需要满足四个核心诉求:

  • 能清楚告诉我当前有哪些工作场景;
  • 切换时按设定把“旧场景的应用和设置”收拾干净;
  • 按设定把“新场景的应用和设置”准备好;
  • 电脑重启后还能恢复到上次切到的场景。

有了这四条,后续所有设计都围绕它们展开。

1.2 一个 context 到底要管理哪些要素

任何一个“工作状态”落到电脑上,其实就四件事。

第一是运行哪些应用。比如 coding 场景要起 VS Code、iTerm、数据库客户端;writing 场景要起 Typora、浏览器、截图工具。第二是系统设置和外观:勿扰模式开不开、桌面壁纸换不换、音量多大。第三是默认工作目录:coding 场景打开终端默认就在项目仓库根目录,写方案时默认在笔记目录。第四是环境变量或扩展动作:进入某个场景时自动加载特定的命令别名、环境变量,或者执行一段通知脚本。

一个 context 就是这四者的组合。为什么不直接写成“一个场景一个脚本”?因为脚本写死在代码里,每改一个场景就得改代码、跑测试、记不住;而把场景声明成配置,让同一个切换引擎反复使用,后续加场景只需要加一段配置,逻辑完全不用动。

1.3 为什么不用现成的自动化工具

很多朋友第一反应是:用现成的快捷键工具(macOS 上的 Alfred、Windows 上的 AutoHotkey)写一堆热键不就行了?我最初也这么试过,但很快发现三个问题。

一是热键只是“触发”,场景本身的组织没有解决。场景一多,每个场景对应一个热键,脑子记不住哪个热键对应哪个场景。

二是系统设置切换(勿扰、壁纸)和应用的启停很难统一管理,用热键工具写起来极其繁琐,里面全是轮询和延时,速度不稳定。

三是缺少“状态”的概念。热键只管执行某个动作,无法告诉我当前在哪个 context,无法做幂等切换。比如已经切到 coding 了,再切一次不应该重复开应用。

所以我要的是一个有“当前状态”概念的引擎,而不是一串一次性脚本。这个决定是整个项目最重要的一步。后面所有代码都围绕一个中心:维护“当前 context”这个状态,然后在它之上做 enter 和 exit 动作。

2. 核心细节:配置、状态和切换规则是怎么定的

2.1 配置格式选型:为什么用 YAML

工具最开始用 JSON 做配置格式。JSON 在 Python 里解析方便,规范清晰,但用的时候发现一个非常难受的点:没法写注释。每个 context 要开哪些应用、为什么这么配,时间一长根本想不起来,只能在旁边放一个文档,维护成本很高。

后来换成 YAML,原因很简单:支持注释,配置自解释;层级结构读起来像人写的说明文档,不需要括回嵌套;在不追求极端复杂的情况下,YAML 的解析复杂性完全可控。

我用的配置长这样:

# ~/.context-mode/config.yaml default_context: coding contexts: coding: title: "写代码" apps: - name: "Visual Studio Code" command: "code" quit_delay: 3 - name: "Terminal" command: "iTerm" workdir: "~/projects/demo" settings: notification_mode: "do_not_disturb" wallpaper: "~/Pictures/coding.jpg" on_enter: - echo "进入 coding 上下文" on_exit: - echo "离开 coding 上下文" writing: title: "写文档" apps: - name: "Typora" command: "typora" - name: "Google Chrome" command: "chrome" workdir: "~/notes" settings: notification_mode: "off" wallpaper: "~/Pictures/writing.jpg" rest: title: "休息" apps: [] settings: notification_mode: "off" wallpaper: "~/Pictures/rest.jpg" on_enter: - osascript -e 'say "休息时间到"'

这里每个字段都对应前面说的“四要素”:apps 对应应用组,workdir 对应默认工作目录,settings 对应系统设置,on_enter/on_exit 对应进入和退出时的扩展动作。

关于这个配置,有两点要解释清楚。

第一,为什么 apps 里要同时写 name 和 command?因为不同平台启动方式不一样。macOS 可以用 open -a 按应用名打开,Windows 要用 Start-Process,Linux 则通常直接敲命令。为了兼容,我保存了“人类可读的名字”和“真正执行用的命令行”,运行时再根据平台选启动方式。

第二,on_enter 和 on_exit 是数组,不是单个字符串。这意味着一个上下文可以同时做多件额外的事,比如进入休息场景时除了关应用,还可以弹出提醒。设计成数组可以让扩展简单,不用为每一种操作单独发明字段。

2.2 三阶段切换流程:进入、退出、恢复

有状态之后,切换动作就必须分阶段做,不能一上来就杀进程。我设计的流程是三段式。

退出旧上下文(exit):遍历旧 context 的 apps 列表,对每个 app 执行关闭动作;把旧 context 的系统设置恢复成默认值;执行 on_exit 钩子。

进入新上下文(enter):按 apps 列表逐个启动应用;设置新 context 的系统设置;切换到默认工作目录;执行 on_enter 钩子。

恢复现场(resume):如果系统刚重启,读取状态文件里保存的“最后一次进入的 context”,自动执行一次 enter,让电脑恢复到上次关机前的工作状态。

为什么把 exit 和 enter 分开,而不是写成一个“先全部重置再全部启动”?因为有时候你需要的不是完整切换,而是临时离开。比如写代码时突然要回个消息,你希望先把 coding 的提示音和通知关掉,但保留“当前 context 是 coding”这个标记。如果写成一个重置流程,下次想回来就得手动确认。分开之后,临时离开就等于是从 coding exit,回来时只要 resume 就能回到 coding 的整套环境。

实际代码里用了一个简单的状态文件,路径在 ~/.context-mode/state.json,内容只有一个字段 current_context。每次成功切换之后,就把新 context 的名字写进去。下次运行时先读这个文件,就知道当前在哪一个 context。

2.3 几个容易翻车的细节处理

这几个细节如果不处理,工具很容易在真实使用中出问题,我整理一下。

第一个是“重复启动”。如果某个应用已经在运行,enter 阶段再执行一次 open/start 命令,一般无害,但有一些应用会弹出“是否打开新实例”的对话框,很烦。解决方式是启动前先判断进程:通过 pgrep 匹配进程名,如果已经存在就跳过,只把窗口带到前台。

第二个是“退出时误杀”。exit 阶段要关的是“旧 context 的应用”,但是我可能同时开着聊天软件、音乐软件,这些不属于任何 context 的“全局应用”,永远不该被 exit 关掉。所以配置里必须能区分两类:一类是 context 管理的、会被关;一类是全局常驻的、永远保留。我在配置里加了一个全局的 global_apps 列表,凡是在这个列表里的应用,任何 exit 都不会碰。

第三个是“设置回滚”。比如 coding 开了勿扰,切到 writing 时如果 writing 配置里没有写 notification_mode,我应该把它恢复成默认值(通知开启),而不是沿用 coding 的值。换句话说,缺失的配置项代表“不修改”,但不代表“不还原”。为了做到这一点,我在 switch 前后各保存一份系统设置快照,exit 时先回滚旧 context 设置,enter 时再应用新 context 设置。

这三个坑分别对应了状态机的幂等、白名单保护、配置还原,处理完之后工具才敢真正在日常中使用。

3. 实操记录:一行命令切换整套环境

3.1 整体结构与技术栈

工具的技术栈特别简单:Python 3 + 系统自带命令,唯一的外部依赖是 PyYAML。之所以用 Python,是因为它不需要编译,改代码直接跑,适合个人工具快速迭代;subprocess 控制外部进程的能力足够成熟;在 Windows、macOS、Linux 上都有官方解释器,跨平台基础好。

目录结构是这样的:

~/.context-mode/ ├── config.yaml # 上下文配置 ├── state.json # 当前状态(自动生成) ├── logs/ # 切换日志 │ └── switch.log └── cm.py # 主程序

主程序核心逻辑集中在几个函数里:load_config(加载配置)、exit_context(退出旧上下文)、enter_context(进入新上下文),再加一个 switch 调度函数。剩下的都是平台兼容代码和命令行参数解析。

3.2 读取配置与校验

配置加载这部分,我一开始写得很随意,YAML 读进来直接用。结果有一次配置缩进错了,提示信息特别难懂,花了好久才发现 apps 底下少了两格。后来在加载配置后加了一层校验逻辑:每个 context 必须要有 apps 字段,apps 里的每个 item 必须要有 name 字段,workdir 如果写了必须存在。校验失败直接报“配置错误:第 2 个 context 的第 1 个 app 缺少 name 字段”这种精确的信息。

这段代码非常简单,但价值非常大。工具运行频率高,配置改错了如果能 1 秒内定位,使用体验会完全不一样。

3.3 切换动作的核心实现

核心的 switch 逻辑,我贴一段简化后的代码,重点是整体框架:

import json import os import platform import subprocess import time import yaml CONFIG_PATH = os.path.expanduser("~/.context-mode/config.yaml") STATE_PATH = os.path.expanduser("~/.context-mode/state.json") IS_MAC = platform.system() == "Darwin" IS_WINDOWS = platform.system() == "Windows" def load_config(): with open(CONFIG_PATH, "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) return cfg def save_state(context_name): with open(STATE_PATH, "w", encoding="utf-8") as f: json.dump({"current_context": context_name}, f) def load_state(): if not os.path.exists(STATE_PATH): return None with open(STATE_PATH, "r", encoding="utf-8") as f: return json.load(f).get("current_context") def app_is_running(app_name): if IS_MAC or not IS_WINDOWS: result = subprocess.run(["pgrep", "-x", app_name, "-f"], capture_output=True) else: result = subprocess.run( ["tasklist", "/FI", f"IMAGENAME eq {app_name}.exe"], capture_output=True, text=True, ) return "(null)" not in result.stdout def open_app(app_command): if IS_MAC: subprocess.Popen(["open", "-a", app_command]) elif IS_WINDOWS: subprocess.Popen(["cmd", "/c", "start", "", app_command]) else: subprocess.Popen(["xdg-open", app_command]) def close_app(app_name, quit_delay=3): if IS_MAC: subprocess.run(["osascript", "-e", f'tell application "{app_name}" to quit']) elif IS_WINDOWS: subprocess.run(["taskkill", "/IM", f"{app_name}.exe", "/F"]) else: subprocess.run(["pkill", "-x", app_name]) time.sleep(quit_delay) def apply_settings(settings): if "notification_mode" in settings and IS_MAC: mode = settings["notification_mode"] subprocess.run( [ "defaults", "write", "com.apple.notificationcenterui", "doNotDisturb", "1" if mode == "do_not_disturb" else "0", ] ) if "wallpaper" in settings and IS_MAC: # 简化示例:实际需要调 API 或 osascript 设置壁纸 pass def run_scripts(scripts): for script in scripts: subprocess.Popen(script, shell=True) def exit_context(ctx, global_apps): for app in ctx.get("apps", []): name = app.get("name") if name and name not in global_apps and app_is_running(name): close_app(name, app.get("quit_delay", 3)) apply_settings({"notification_mode": "off"}) run_scripts(ctx.get("on_exit", [])) def enter_context(ctx): workdir = os.path.expanduser(ctx.get("workdir", os.path.expanduser("~"))) os.chdir(workdir) apply_settings(ctx.get("settings", {})) for app in ctx.get("apps", []): command = app.get("command", app.get("name")) if command and not app_is_running(app.get("name", command)): open_app(command) time.sleep(0.5) run_scripts(ctx.get("on_enter", [])) def switch(target): cfg = load_config() if target not in cfg["contexts"]: print(f"未知 context: {target}") return global_apps = cfg.get("global_apps", []) current = load_state() if current and current != target: print(f"退出 {current}") exit_context(cfg["contexts"][current], global_apps) print(f"进入 {target}") enter_context(cfg["contexts"][target]) save_state(target) print("切换完成") if __name__ == "__main__": import sys target = sys.argv[1] if len(sys.argv) > 1 else "rest" switch(target)

这段代码可以看出几个关键点。

一是退出顺序一定在进入之前。先关掉旧 context 的应用,再启动新 context 的应用,避免两套应用同时开着,窗口和资源都混乱。

二是每个 app 启动之间有 0.5 秒的 sleep。这个时间不是随便拍的。很多应用在启动时会抢同一个系统资源,如果全部并行启动,有些安全软件会拦截,导致应用启动一半就挂掉。给 0.5 秒间隔,整体速度几乎无感,成功率会高很多。

三是 app_is_running 和 close_app 的匹配关系。macOS 上应用名可能和命令名不一致,比如“Google Chrome”的进程名是“Google Chrome”,但命令行是 open -a "Google Chrome"。所以在配置里保留 name 字段作为人类名字,command 字段作为机器名,两者分开匹配,避免误杀。

四是关闭应用尽量用优雅方式,不要直接用 kill。macOS 上我用 AppleScript 的 quit 命令,给应用保存和退出的时间;Windows 上 taskkill /F 是强制模式,因为很多 Windows 命令行工具本身不带优雅退出接口,只能在配置里把关键应用加进 global_apps 白名单,避免误杀。

3.4 绑定快捷键与开机恢复

命令行工具本身用起来已经很爽了:一条 python cm.py coding 就能切换。但人的肌肉记忆更倾向快捷键,所以我把它绑到了热键上。

macOS 我用 Alfred Workflow,关键字 cm 后面接 context 名。Windows 用 AutoHotkey,写一行 #c::Run python C:\Users\me.context-mode\cm.py。Linux 用全局快捷键系统直接绑定。

绑定热键时有几个细节值得注意。

  • 不要把所有 context 绑成不同热键,记忆负担太大。应该只绑一个“呼出菜单”的热键,然后用一个小菜单选择 context。
  • 我实际的方案是:一个热键打开由 cm.py --list 生成的菜单列表,选择后执行 cm.py 。这样即使以后 context 增加到 10 个,都不需要新增热键。
  • 开机恢复用系统自带的“登录时启动”机制,在 macOS 的 LaunchAgent、Windows 的启动文件夹、Linux 的 autostart 目录里分别放一个启动项,运行 cm.py --last。--last 做的事就是读取 state.json,如果里面有 current_context 就自动 enter 一次。

这个功能很实用。前一天下班时切到了休息 context,第二天开机如果直接恢复到休息,等想写代码时再手动切,就避免了开机顺手打开一堆工作应用的惯性,也给正式工作前留了一个明确的“切换”动作。

3.5 日志与状态持久化

个人工具不做日志也可以,但做了之后排查问题效率高很多。我只是简单地往 logs/switch.log 追加文本,每行记录一次切换的时间、旧 context、新 context、启动和关闭了哪些应用。

实际执行时,关键节点打印带时间戳的信息:

2025-06-01 10:12:33 | 切换开始 | rest -> coding 2025-06-01 10:12:34 | 退出 app: Typora 2025-06-01 10:12:35 | 进入 app: Visual Studio Code 2025-06-01 10:12:36 | 切换完成 | 耗时 3.2s

这里有个小技巧:每个应用启动,都记录开始时间和结束时间,这样如果某个 context 切换变慢,可以直接从日志里看出是哪一个应用拖慢了整体。

日志的作用不只是事后排查,还能帮助调整配置。比如一开始给所有应用都设了 quit_delay=3,后来看日志发现大部分应用 1 秒内就退完了,就把多数应用改成 quit_delay=1,整体切换速度立刻快了 4 秒。

4. 常见问题与排查技巧实录

4.1 切换一半卡住,应用一个都没起来

这个问题我第一次遇到时差点把工具删了。检查半天,最后发现是配置里的 workdir 写了一个不存在的目录,Python 的 os.chdir 直接抛异常,整个脚本在中途退出,旧应用关了新的也没开。

排查思路其实很简单:先用调试模式跑一遍,不执行任何真实操作,只打印每一步会做什么。这样可以快速区分是配置问题、权限问题,还是脚本自身问题。

针对这个问题我总结了一条经验:所有可能引发异常的外部依赖(目录、命令、权限),都应该在切换前先做一次预检。现在每次切换前,脚本都会检查一遍 workdir 是否存在、每条 app 的 command 是否能在 PATH 里找到,如果校验失败就直接中止,不会出现“关一半”的尴尬局面。

4.2 应用已经开了,但 context 切换后又被关掉

这是典型的“白名单”问题。我之前把浏览器配在 coding 的 apps 里,后来实际使用中发现一个重要事实:浏览器是全局通用工具,不应该绑死在某个 context 上。于是把浏览器加进了 global_apps,任何 context 切换都不会碰它。

还有个变体问题:同一个应用被配置进多个 context。比如 coding 和 writing 都包含 iTerm,那么从 coding 切到 writing 时,exit 会尝试关 iTerm,enter 又尝试开 iTerm,反复横跳。我写的 switch 逻辑里会先比对新旧 context 的 apps,如果某个应用同时出现在两边,就在 exit 时跳过它。

4.3 配置文件写错,解析出莫名其妙的结果

YAML 对缩进敏感,很多人都会栽在这里。一个典型的错误是 apps 下面的缩进不一致,导致 writing 被当成 coding 的某个字段,结果 context 列表只剩一个 coding。这个问题用 PyYAML 解析时不会报错,而是静默地产生错误结构。

我的处理方式是给 load_config 加 schema 校验,校验项不复杂:

  • contexts 必须存在,且非空;
  • 每个 context 必须有 apps 字段,类型为 list;
  • 每个 app 必须有 name 字段;
  • command 字段如果存在,必须是一个字符串。

校验失败时会直接指出第几个 context、哪个字段出错。这个校验函数只有二十几行,但帮我省下了大量排查时间。

4.4 快捷键没有反应,或触发了错误 context

快捷键绑定问题,通常和权限有关。macOS 上如果终端窗口缺少辅助功能权限,osascript 控制应用会被拒绝,结果就是“看起来运行了但什么都没做”。Windows 上 AutoHotkey 需要以管理员权限运行,否则无法控制一些以管理员身份启动的应用。

排查思路:

  • 先在终端里手动执行 python cm.py ,确认命令本身没问题;
  • 再检查日志有没有记录到此次切换;
  • 再检查热键工具的按键映射是否被其他程序劫持;
  • 最后检查系统权限。

还有一个细节:macOS 上 open -a 是按应用名找应用,如果应用名带后缀或不匹配,会打开失败。最好在终端先跑一下 open -a 验证能否启动,如果失败,换用完整路径。

4.5 实际排查速查表

我把最常遇到的几个现象、原因和排查动作整理成一张表,方便直接对照:

现象可能原因排查与处理
切换后应用没起来workdir 不存在 / command 找不到用调试模式逐个检查每条命令
切换后应用被误关应用没在白名单,或同时出现在新旧 context检查 global_apps,检查 apps 列表
日志显示成功但实际无变化权限不足终端手动执行一次,查看报错
配置解析后 context 变少YAML 缩进错误用 yaml.safe_load 打印解析结果对照
切换特别慢quit_delay 设置偏大调日志中单 app 耗时,按需调小 delay
开机没有自动恢复autostart 项没加 / 权限不足手动执行 --last 试试,再检查 autostart

这张表现在放在工具仓库的 README 里,有朋友来问问题,对着表基本能解决 80% 的情况。

4.6 给工具加个刹车:可撤销与手动确认

切换本身会不会造成不可逆损失?会的,最大的风险点是退出应用时保存文档。任何自动化工具都不应该在用户还没保存文档时强制退出应用。所以我的 exit 逻辑有一个安全检查:如果某个应用进程的窗口标题匹配“未命名”“未保存”,就跳过关闭,输出警告。虽然不一定全面,但至少覆盖了最常见的风险。

在使用层面,我也养成了一个习惯:重要的文档加入到“手动确认关闭”列表。配置文件里可以给某个 app 加 force: false,表示退出前需要弹窗询问。第一次做这个功能的时候,我在写文档时误触了热键,差点丢掉一篇稿子,后来才加了强制确认机制。说实话,工具比人还冲动的时候,给工具加刹车非常重要。

5. 最后说几点实操体会

工具跑通之后,我最大的感触是:别一上来就追求“全家桶式”的自动化。把最小可用的版本先跑起来,先管理两三个应用,慢慢加配置,比一开始就堆一大堆字段靠谱得多。我的第一版只有两个 context、四个应用,跑了两周稳定了,才逐步扩展到现在的状态。

另一个体会是,工具是给人用的,切换速度从 8 秒优化到 3 秒,体感并没有想象中那么大;真正让人爽到的是每次切换之后,电脑真的变成了我想要的那个状态。把“设置恢复”和“防误杀”做好,远比单纯追求切换速度更有价值。

最后,如果你也想做一个类似的东西,建议从整理自己的固定流程开始:先列出一周高频切换的场景,每个场景只需要必备的 3 到 5 个应用,然后用最简单的脚本跑通,再慢慢补细节。context-mode 对我来说最大的收获不是节省了这几分钟,而是让我重新审视了自己的工作流:原来我浪费在“找状态”上的时间,远比想象的多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进