前言:又一次"从头开始"
我上一台机器是 2025 年四月买的 Mac Studio M4 Max 128GB,用了一年多,状态依旧不错。最近发现二手市场上溢价比预想的要高不少,索性卖了换 MacBook Pro M5 Max 128GB,同样的内存预算,从桌面挪回笔记本。
但这篇博客不会聊"M5 Max 跑分多猛"或者"换机第一感受",要写的是一件更乏味、也更实际的事:趁着这次重装,把过去几年环境上欠下的债一次性还掉。
上一台机器陪我跑了一整年的项目,攒下了一身病:
- Homebrew 里近 300 个包,其中至少一半我已经想不起当初为什么装。
brew list像一个考古现场。 - oh-my-zsh 启动稳稳一秒起步。oh-my-zsh 本身没毛病,问题出在我一路堆上去的几十个插件,每一个都在"以防万一"的名义下贡献着可观的同步加载时间。
- Node / Python / Go 在全局到处是:有 brew 装的、有 nvm 装的、有 asdf 装的、还有忘了从哪装来的。
which node的结果时常让我惊讶。 - Docker Desktop 常驻吃 8 GB 内存,还要时不时跟 VPN 打架。在 128GB 的机器上看似无伤大雅,但它的 license 政策和 VM 实现本身就已经让人想换。
这次我想把思路反过来:让环境状态本身可以被版本控制。下次再换机器,我不应该再写一篇类似的总结,而是 git clone 加几条命令就把这台机器变回今天的样子。
所以这篇的重点是 为什么这么选。命令我也会贴,但只是辅助;想留下来的,是每个选择背后的取舍,以及一年后我自己回头看时,希望仍然认同的那部分判断。如果你想要的是一份"复制粘贴就能用"的脚本,可以直接跳到结语看 dotfiles 仓库链接。这篇正文更多是写给一年后忘了为什么这么配的我自己看的。
核心原则:工具链跟项目走,不跟机器走
如果非要总结这次梳理的中心思想,就是这一句话。
之前我的 .zshrc 里有这么一段东西,不少同行应该眼熟:
# 旧配置(已经被我抛弃)
export ANDROID_HOME="$HOME/Library/Android/sdk"
export JAVA_HOME=/Library/Java/JavaVirtualMachines/openjdk-17.jdk/...
export PATH="/opt/homebrew/opt/ruby/bin:$PATH"
export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"
source "$HOME/.cargo/env"
eval "$(fnm env --use-on-cd --shell zsh)"
pyenv() { ... 懒加载 stub ... }
fnm() { ... 懒加载 stub ... }这一堆东西的存在意义只有一个:让我能在任何目录下 cargo、node、python、java 一把梭。麻烦也来了:
.zshrc启动慢。为了对抗这个慢,我还专门写了懒加载 stub,绕了一大圈。- 版本不可控。
fnm装的 Node 22.5,brew 升过的 Python 3.12.4,跟队友机器永远对不齐。 - CI 行为漂移。本地一个版本 CI 另一个版本,bug 排查靠魔法。
- 离职 / 换机重建难。这套环境是十几个
brew install+ 几个curl | bash慢慢攒出来的,无法声明式复现。
新机器我决定把这个心智模型反过来:版本相关的工具,永远只在需要它的项目目录里存在。cd 进项目自动出现,cd 出项目自动消失。全局只留真正"装在机器上"的东西:GUI app、版本无关的命令行工具、git / starship 这种基础设施。
实现这个反转的工具是 devenv + direnv(建在 Nix 上)。
包管理:Brew + Nix 的分工
承担这个反转之后,两个包管理器各自的边界变得清晰:
| Homebrew | Nix | |
|---|---|---|
| GUI 应用 / Cask | ✅ 主场 | ❌ macOS 上几乎不可用 |
| 字体、macOS 系统工具 | ✅ 主场 | △ 能装但不舒服 |
| 版本无关的 CLI | ✅(starship、git、direnv……) | △ 也能装,选一边即可 |
| 项目级语言工具链 | ❌ 全局污染 | ✅ 主场 |
| 服务(Postgres / Redis) | △ 全局单实例 | ✅ 每项目独立 |
| 可复现性 | ❌ 谁都不知道你装了啥 | ✅ flake.lock / devenv.lock |
我的 Brewfile 因此瘦了一圈:
# === GUI casks ===
cask "raycast"
cask "ghostty"
cask "zed"
cask "1password"
cask "font-jetbrains-mono-nerd-font"
# === 版本无关的 CLI ===
brew "git"
brew "starship"
brew "direnv"
brew "atuin"
brew "jq" ; brew "ripgrep" ; brew "fd" ; brew "bat" ; brew "eza"
# === GPG / smart card stack ===
brew "gnupg"
brew "pinentry-mac"
# 注意:这里没有 node / python / ruby / go / rustup
# 也没有 pyenv / fnm / rbenv / asdf
# 那些都归 Nix 管brew uninstall 的时候反而是大头:
brew uninstall node [email protected] go ruby openjdk@17 \
pyenv fnm rbenv jenv asdf postgresql redis 2>/dev/null
brew autoremove && brew cleanup清完之后再装 Nix,从此 PATH 里只剩一种关于工具链的存在方式。
装 Nix 的几个坑
别用 brew install nix
Homebrew 没有 nix formula,即便有人写了 tap 也不能用。macOS 自 Catalina 起根目录受 SIP 保护,Nix 需要 /nix 必须是一个独立 APFS volume,brew formula 没权限干这事。多用户 nix-daemon 要注册成 LaunchDaemon,brew 也不管这个。
社区共识是用 Determinate Systems 的 installer:
curl --proto '=https' --tlsv1.2 -sSf -L \
https://install.determinate.systems/nix | sh -s -- install它会引导你选择 Determinate Nix 还是 upstream Nix。如果你之后打算用 nix-darwin 管理整台 Mac,必须选 upstream(Determinate Nix 目前和 nix-darwin 不兼容)。我个人只想要 per-project 环境,所以选了 Determinate,macOS 大版本升级的鲁棒性明显更好。
最大的优势是 /nix/nix-installer uninstall 真的能干净卸载。"先试试,不喜欢就走人"的安全感,让新人想试一试也没什么顾虑。
trusted-users 这个一次性痛苦
装完 Nix,直接 devenv shell 跑 Rust 项目,被一脸警告砸:
warning: ignoring the client-specified setting 'system',
because it is a restricted setting and you are not a trusted user
× Failed to get drvPath from shell derivation原因是 multi-user Nix 默认只允许 root 是 trusted user。普通用户能跑基础 nix 命令,但任何项目想精确指定构建参数(比如 languages.rust.channel = "stable" 会触发 daemon 的 system 参数 override)都会被 daemon 静默拒绝。
修法是编辑 /etc/nix/nix.conf:
extra-trusted-users = @admin注意是 extra-trusted-users 而不是 trusted-users。前者是"在 Determinate 自己的设置基础上追加",不会被升级回滚。直接写 trusted-users 会覆盖 installer 预设值,Determinate 自带的 cache 配置会丢失。
然后重启 daemon:
sudo launchctl list | grep -i nix # 找出真实 daemon 名
sudo launchctl kickstart -k system/systems.determinate.nix-daemon(Determinate 装的 daemon 叫 systems.determinate.nix-daemon,upstream 是 org.nixos.nix-daemon,网上很多教程不区分。)
改一次,从此项目里所有的 substituter / cache / 工具链版本声明都正常工作。一次性的痛苦,持续的收益。我觉得这是 Nix 安装应该默认引导的一步,但目前还没有。
devenv + direnv:每个项目的可复现环境
铺垫这么多就是为了这一节。直接看一个真实例子,我那个 Rust 项目的 devenv.nix:
{ pkgs, lib, ... }: {
env = {
RUSTC_WRAPPER = "sccache";
RUST_BACKTRACE = "1";
};
packages = with pkgs; [
git
sccache
];
languages.rust = {
enable = true;
channel = "stable";
components = [
"rustc" "cargo" "clippy" "rustfmt"
"rust-analyzer" "rust-src"
];
};
enterShell = ''
echo "🦀 $(rustc --version)"
'';
}配套 .envrc:
use devenvdirenv allow 一次,以后 cd 进这个目录,Rust toolchain、sccache、所有声明的包自动激活;cd 出去自动卸载。全局 PATH 完全干净,我不需要装 rustup,也不需要懒加载 stub。
关于"会不会每个项目都重新下载一份 Rust"——不会。Nix store 按 hash 去重:5 个项目都用 stable 1.84,磁盘上就一份。只有不同版本才占额外空间。nix-direnv 还会给每个项目建 GC root,跑 nix-collect-garbage 不会误删正在用的版本。
几个我踩过的坑
坑 1:languages.rust.channel 需要额外 input
第一次 direnv reload 会报:
error: To use 'languages.rust.channel', run the following command:
$ devenv inputs add rust-overlay github:oxalica/rust-overlay --follows nixpkgs照着跑就行。这是 devenv 的设计哲学:默认 inputs 越少越好,Rust 这种需要精确控版本的工具链对应的 rust-overlay 是按需添加的,不强加给所有项目。
坑 2:direnvrc 里要有 use_devenv 定义
错误信息:
direnv: using devenv
... use_devenv: command not found我之前 ~/.config/direnv/direnvrc 只 source 了 nix-direnv,它只提供 use_flake / use_nix,不提供 use_devenv。补齐:
# ~/.config/direnv/direnvrc
source "$HOME/.nix-profile/share/nix-direnv/direnvrc"
eval "$(devenv direnvrc)"坑 3:Apple SDK 命名空间已经废弃
之前网上很多 Rust + macOS 的 devenv.nix 模板会写:
# 已废弃,新版 nixpkgs 报错
darwin.apple_sdk.frameworks.Metal
darwin.apple_sdk.frameworks.Foundation
libiconv新版 nixpkgs(25.05+)统一把这一堆全部砍掉了。默认 Darwin stdenv 自动准备整个 SDK,Metal / Foundation / CoreGraphics 全部 framework 都开箱可用,libiconv 也自动传递。正确的写法是什么都不写:
packages = with pkgs; [
git
sccache
];
# ← 不需要任何 darwin / apple-sdk 相关声明只有少数项目需要锁定具体 SDK 版本(为旧 macOS 编译、跨编译等),才用 apple-sdk_15 / apple-sdk_26 这种新的伞包。99% 的情况下不写最稳。
容器:用 ArcBox 替代 Docker Desktop
Docker Desktop 在上一台机器上常驻吃 8 GB 内存,VPN 切换会让网络抽风,license 政策也让人不爽。它是这次重置我最迫切想干掉的东西。
2026 年这个时间点上,macOS 上的替代品基本只剩两个:ArcBox Desktop 和 OrbStack。Docker Desktop 我连考虑都懒得考虑。
先把利益声明放在前面:我是 ArcBox 的开发者之一,所以这条选择天然有偏见。但即便把这个偏见去掉、作为外部用户重新评估一次,我依然会选 ArcBox。OrbStack 在原生 macOS 集成上做得非常好,性能层面两者基本是平手;差别就在两件事:
- 本地 / 云端一致。ArcBox Desktop 和 ArcBox Cloud 共享同一套 Sandbox API:本地起的容器和云端 PaaS 跑的容器在 spec 层面是同一个抽象。我不再需要"本地 Docker → 云上 ECS / Fly / Railway"这种两套抽象的中间层。这是 OrbStack 不做的事,它专注于本地。
- 完全开源。Runtime、CLI、Desktop UI 全部代码可审计。我作为用户在意可持续性,作为开发者更在意它能保证一直是这样。OrbStack 的核心是闭源的,长期看是一种风险。
工作流分工很简单:
- 临时容器(
docker run级别的一次性进程)走 ArcBox CLI。 - 长期跑的本地服务(Postgres、Redis、MinIO 这类)不打包成镜像,交给 devenv 起 process,毫秒级启动,资源开销和普通进程没区别。
- 完整 OCI 镜像 / cross-arch build 才进 ArcBox 的 VM。
ArcBox Desktop 不在 Homebrew,从官网下载独立 dmg 安装。Brewfile 里看不到它是有意为之——这一类需要深度集成系统(虚拟化、网络 hook)的 GUI 工具自己管自己的更新通道,比 brew cask 跟上游 release 更及时。
终端:Ghostty
iTerm2 用了八年,WezTerm 试过半年,Alacritty 用了一阵。这次换机我装的是 Ghostty。
几个原因:
- 零配置就能用。打开就是合理的默认。我至今给它写过的全部配置是 8 行;iTerm2 我导出过 200 行的 plist。
- 真原生。Cocoa + Metal,没有 Electron,没有 webview。冷启动 < 100 ms,比 Terminal.app 还快。
- 现代特性该有的都有。原生 split / tab,shell integration(自动 cwd 同步、命令分段、jump-to-prompt),Quick Terminal(全局快捷键唤起的 HUD 式终端,类似 iTerm 的 Hotkey Window 但更轻量),字体 ligature、subpixel anti-alias、shader 都开箱可用。
- 作者是 Mitchell Hashimoto。HashiCorp 创始人离职单干的产品,写代码的密度和品味都看得出来。
我的 ~/.config/ghostty/config 全部内容:
theme = catppuccin-mocha
font-family = JetBrainsMono Nerd Font
font-size = 13.5
window-padding-x = 12
window-padding-y = 12
window-decoration = false
keybind = cmd+shift+enter=toggle_fullscreen
shell-integration = zsh没了。shell-integration 那一行是核心:Ghostty 会在你 cd 时实时把当前目录写到窗口标题、给每条 prompt 打 OSC 标记(这样可以 cmd+up / cmd+down 在 prompt 间跳)。这件事 WezTerm 要写 Lua,iTerm2 要装 shell integration 脚本,Ghostty 一行 = zsh 完事。
WezTerm 我没继续用的原因主要是配置文件太重。Lua-as-config 设计在功能强大的同时也意味着我得维护一个 200 行的 wezterm.lua,而我已经有 dotfiles 仓库要维护了。
Zsh 重构:从 OMZ 到 Zinit + Starship + rc.d/
我用了五年 Oh My Zsh,这次趁换机干脆推倒重来。
性能层:Zinit turbo
Oh My Zsh 加 15-20 个插件后启动 500ms+,主要在于它同步加载所有东西。换成 Zinit 的 turbo 模式后,所有非关键插件推迟到首屏 prompt 出现之后才异步加载,启动时间稳定在 50-100ms,而且功能完全不少。
核心写法长这样:
# Turbo 加载关键插件
zinit wait lucid for \
atinit"ZINIT[COMPINIT_OPTS]=-C; zicompinit; zicdreplay" \
zdharma-continuum/fast-syntax-highlighting \
atload"_zsh_autosuggest_start" \
zsh-users/zsh-autosuggestions \
blockf atpull'zinit creinstall -q .' \
zsh-users/zsh-completionswait lucid 是 turbo 的精髓:wait 表示"等首屏 prompt 出来再加载",lucid 表示静默。
OMZ 也没完全丢——它有些非常实用的 helper 文件(directories.zsh 提供 .. / ... / .... 这类导航别名;git plugin 提供 100+ git 别名),Zinit 支持只 cherry-pick 这些文件而不加载整个 OMZ 框架:
zinit wait lucid for \
OMZL::git.zsh \
OMZL::directories.zsh \
OMZP::git \
OMZP::sudo要的功能一个不少,启动时间还是 OMZ 的零头。
提示符层:Starship 替代 Powerlevel10k
p10k 用了三年,instant prompt 功能确实牛逼,但配置文件 1700+ 行,跨机器同步、编辑都不友好。Starship 同样 Rust 写的、同样快,配置是一个 50 行 ~/.config/starship.toml,而且跨 shell(bash / zsh / fish / nu)体验一致,以后哪天想换 fish 不用重做提示符。
组织层:rc.d/ 模块化拆分
这是这次重构里我最满意的部分。
.zshrc 单文件长到一定程度会变成怪物:history 设置、Zinit、Starship、插件 turbo 加载、OMZ snippets、direnv、Atuin、GPG、aliases、自定义函数……这次我把它们拆成独立文件:
~/.config/zsh/rc.d/
├── 00-history.zsh # HISTFILE / setopt
├── 10-zinit.zsh # bootstrap zinit
├── 20-prompt.zsh # eval $(starship init zsh)
├── 30-plugins.zsh # 关键插件 turbo
├── 40-omz.zsh # OMZ cherry-pick
├── 50-direnv.zsh # direnv hook (turbo)
├── 51-atuin.zsh # atuin (turbo)
├── 60-gpg.zsh # GPG_TTY + gpg-agent launch
├── 70-aliases.zsh
└── 80-functions.zsh # gpgfix 等小工具.zshrc 本身瘦成 6 行:
for _rc in $ZDOTDIR/rc.d/*.zsh(N); do
source "$_rc"
done
unset _rc
[[ -f "$ZDOTDIR/.zshrc.local" ]] && source "$ZDOTDIR/.zshrc.local"数字前缀控制加载顺序,中间留空隙(10、20、30)以后想插队不用 rename。(N) 是 zsh 的 nullglob qualifier,目录不存在时静默返回空列表,而不是抛 no matches found。
想暂时禁用某段配置?mv 60-gpg.zsh 60-gpg.zsh.disabled,glob 不再匹配,无副作用。比 commit-then-revert 干净多了。
启动文件分工:ZDOTDIR + 三个文件各司其职
zsh 的启动文件大多数人塞进 .zshrc 一锅炖,实际上它有清晰的层级:
| 文件 | 谁加载 | 该放什么 |
|---|---|---|
~/.zshenv | 每个 zsh 进程(包括脚本) | ZDOTDIR + XDG vars + LANG + EDITOR |
$ZDOTDIR/.zprofile | 仅 login shell | PATH + Nix daemon + 静态 socket |
$ZDOTDIR/.zshrc | 仅 interactive shell | 插件、提示符、aliases |
.zshenv 是唯一硬编码到 $HOME 的文件,负责设 ZDOTDIR=~/.config/zsh,把后续配置全部重定向到 ~/.config/zsh/ 下面。$HOME 干净得像 NixOS 新装的样子。
为什么 PATH 必须在 .zprofile 而不是 .zshrc?因为 exec zsh 会重跑 .zshrc 但不重跑 .zprofile。PATH 放 .zprofile 一次设置永久生效,放 .zshrc 每次 reload 都会让 PATH 变长(虽然 typeset -U 会自动去重,但白白做了无用功)。
SSH / GPG agent 的精细化路由:1Password + YubiKey 共存
这次还把 SSH / GPG 这一摊也重新整理了。
我的密钥分两块:
- 大部分 SSH key 放在 1Password(
Private+Employee两个 vault,Touch ID 解锁) - GitHub commit signing 用 YubiKey + GPG(因为 git commit signing 在硬件 token 上更稳)
两个 agent 抢同一个 $SSH_AUTH_SOCK,只能选一个当默认。我选的方案是 ~/.ssh/config 里用 IdentityAgent 按 host 路由:
# ~/.ssh/config
Include ~/.ssh/config.d/*.conf
Host *
IdentityAgent ~/Library/Group\ Containers/2BUA8C4S2C.com.1password/t/agent.sock
IdentitiesOnly yes
StrictHostKeyChecking accept-new
UpdateHostKeys yes# ~/.ssh/config.d/github.conf —— GitHub 用 GPG / YubiKey
Host github.com
User git
IdentityAgent ~/.gnupg/S.gpg-agent.ssh
IdentityFile ~/.ssh/keys/github.pubInclude ~/.ssh/config.d/*.conf 是 ssh 自带的 Include 指令,直接对标 nginx conf.d/,把上百行 ssh config 拆成按 host 类别组织的小文件。
1Password agent.toml:控制暴露哪些 vault
key 跨 vault 时,默认 1Password 只暴露 Private vault 里的 SSH key,需要显式声明:
# ~/.config/1Password/ssh/agent.toml
[[ssh-keys]]
vault = "Private"
[[ssh-keys]]
vault = "Employee"顺序很关键,agent 按列出顺序把公钥喂给 server,常用的 vault 放前面减少 Too many authentication failures 风险。配合每个 host 显式 IdentityFile + IdentitiesOnly yes,这个排序就不再敏感,但前者依然是必要的暴露声明。
IdentitiesOnly yes 必须加
1Password 里 key 数量上去之后(20+),不加 IdentitiesOnly yes,agent 会把所有公钥按顺序喂给 server,大概率撞上 MaxAuthTries 6 上限。加了之后只试 IdentityFile 指向的那一把,稳定且快。
公钥的来源
1Password 桌面应用开启 SSH agent 后,有个选项把所有 vault 里 SSH key 的公钥自动写到 ~/.1password/agent-keys/,但默认不开,我的机器上甚至这个目录都不存在。我的做法是自己维护 ~/.ssh/keys/,从 1Password item 复制公钥过来:
mkdir -p ~/.ssh/keys
op item get "GitHub SSH" --fields "public key" > ~/.ssh/keys/github.pub这个目录可以提交到 dotfiles 仓库(公钥不敏感),换机器零摩擦。
known_hosts.d/ 拆分
ssh 的 UserKnownHostsFile 也支持多文件,虽然没有 Include 那种 glob,但能空格分隔:
# ~/.ssh/config.d/work.conf
Host work-*
UserKnownHostsFile ~/.ssh/known_hosts.d/work
# ~/.ssh/config.d/github.conf
Host github.com
UserKnownHostsFile ~/.ssh/known_hosts.d/github这套结构让离职清理一键完成:rm ~/.ssh/known_hosts.d/work 把工作相关 host 指纹瞬间清干净,GitHub 和个人服务器不受影响。比 ssh-keygen -R 一个个删干净得多。
一些细节,顺带写下来
GPG_TTY 必须每个 interactive shell 刷新
# rc.d/60-gpg.zsh
export GPG_TTY=$(tty)$(tty) 在每个 tmux pane / 终端窗口都不一样,如果只在 .zprofile 里设一次,新 pane 里 gpg pinentry 会找错 TTY。必须放 .zshrc(或者 rc.d/60-gpg.zsh 这种入口)。
SSH_AUTH_SOCK 在 SSH 远端要保留转发的 socket
# 只在本地设置;远端 SSH 进来时保留 forwarded socket
if [[ -z "$SSH_CONNECTION" ]]; then
export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)
fissh -A 会自动把 $SSH_AUTH_SOCK 设成 /tmp/ssh-XXX/agent.NNN,你 zprofile 里再覆盖一次就把 forward 干掉了。
gpg --card-status: Service is not running
YubiKey 用户偶发的 scdaemon 故障,根因是 macOS 自带的 PC/SC 服务和 GnuPG scdaemon 抢卡。修法在 ~/.gnupg/scdaemon.conf 加一行:
disable-ccid然后 gpgconf --kill all && gpgconf --launch gpg-agent。我顺手在 rc.d/80-functions.zsh 里写了个 gpgfix 函数封装这个组合,以后偶发挂掉 gpgfix status 一下就好。
编辑器:只用 Zed
我有过几次"all-in 一个编辑器"的尝试:把 Vim 改造成 IDE、把 VSCode 装满 50 个插件、为 Emacs 写过几千行 elisp。最终都回到同一个结论:编辑器是写代码的工具,不是项目本身。配置越少、启动越快、按键反馈越准,越好。
这次换机我只装了一个 Zed。
选 Zed 的理由:
- 足够快。Rust + 自研 GPUI,从按键到屏幕的延迟在我用过的所有编辑器里最低,VSCode 系完全比不了。
- 真原生。Cocoa 实现,不是 Electron 套娃。冷启动 < 300 ms。
- AI 集成够用。内置 Agent Panel 接 Claude / GPT,日常 LLM 协作不需要再装 Cursor 那一套。
- 协同编辑开箱即用。Channels + shared workspace,pair programming 直接发链接,VSCode Live Share 至今没做好的事。
- 配置极简。一个
~/.config/zed/settings.json,没有 1700 行 plugin config 要维护。
Cursor 我没继续用。VSCode 内核本身的卡顿、Electron 的内存占用、那一堆我不需要的插件市场,足以盖过 AI 协作那点优势。Neovim 我留在远端机器上偶尔 ssh 进去顺手改文件,本地不装。Xcode / JetBrains 按需 App Store / Toolbox 装,不放进 dotfiles。
机器上还装了什么:GUI 应用清单
不写完整的 brew bundle dump,按用途分类,每类一句话理由。剩下的 SaaS 客户端(Slack / Telegram / Linear / Discord / iCloud)就不列了:
- 启动器:Raycast。走 Beta 通道,AI Commands 和实验功能比 stable 早几个月用到。Spotlight + Alfred + 剪贴板历史 + AI 助手一并取代。
- 应用 / 窗口切换:AltTab。macOS 原生
cmd+tab只切应用、不切窗口,AltTab 提供 Windows / Linux 风格的 alt+tab 全窗口缩略图切换。试过 Aerospace 这类 tiling WM,最后发现自己 80% 时间只需要"在 Chrome 五个窗口里找一个",AltTab 解决这一件事就够了。 - 密码 / SSH key:1Password。SSH agent 那一节已经详细写过。
- 代理 / 分流:Surge 5。配合自写规则集,VPN / 直连 / 分流策略全部可声明。
- 截图 / 录屏:CleanShot X + Snipaste。CleanShot 负责标注、滚动截图、录制 GIF;Snipaste 负责"把截图钉在桌面上",这是 macOS 原生缺的能力,看着旧设计稿写新代码时尤其有用,两者分工互补。
- 翻译:Bob。接入 OpenAI / DeepL API,划词翻译比内置词典强多了。
- 系统维护:Sensei。SSD trim、电池健康、温度 / 风扇监控、应用残留清理,macOS 自带工具拼不出来这套面板。
总的原则跟前面一样:GUI 应用归 Brewfile(或独立 dmg),能 brew cask 装的都进 Brewfile,剩下的少数(ArcBox、Surge、Sensei 这种)走自己的 installer。
完整结构 & 仓库
最终 dotfiles 仓库的结构:
dotfiles/
├── REFERENCE.md # 给未来的我看
├── Brewfile
├── zsh/
│ ├── .zshenv # → ~/.zshenv
│ ├── .zprofile # → ~/.config/zsh/.zprofile
│ ├── .zshrc # → ~/.config/zsh/.zshrc
│ ├── rc.d/ # → ~/.config/zsh/rc.d/
│ └── functions/
├── direnv/
│ └── direnvrc
├── gnupg/
│ ├── scdaemon.conf # disable-ccid
│ └── gpg-agent.conf
├── ghostty/
│ └── config
├── zed/
│ └── settings.json
├── ssh/
│ ├── config
│ └── config.d/
│ ├── github.conf
│ ├── personal.conf
│ └── work.conf
└── starship.toml新机器初始化大概这几步:
git clone <repo> ~/dotfiles
brew bundle --file=~/dotfiles/Brewfile
curl --proto '=https' -sSf -L https://install.determinate.systems/nix | sh -s -- install
nix profile install nixpkgs#devenv nixpkgs#nix-direnv
# symlink configs(用 chezmoi / stow 自动化,或手动 ln -sf)
sudo $EDITOR /etc/nix/nix.conf # 加 extra-trusted-users = @admin
sudo launchctl kickstart -k system/systems.determinate.nix-daemon
exec zsh剩下的就是 1Password 登录、SSH key 配 IdentityFile、GPG card 初始化、ArcBox Desktop 装一下,这些是有"我"的成分的,不可能完全 dotfiles 化。
写在最后
这次梳理没有用什么炫酷的新工具:Nix 不新,Zinit 不新,1Password SSH agent 也都两年了,Ghostty 也已经 1.0 发布过了。但把它们放在一起重新设计边界:
- 机器安装的:GUI app + 版本无关的 CLI(Brew)
- 用户层面的:全局 SSH key、GPG card、shell 配置、dotfiles
- 项目自己的:语言工具链、服务、构建依赖(Nix devenv)
这三层之间的隔阂越清晰,系统越稳。所有"哎我这台机器怎么和那台不一样"的诡异 bug,都是这三层之间漏水。重新梳理一次,补好所有缝,以后换机、加机、和队友对齐环境,都是几条命令的事。
新机器买回来的兴奋很容易让人想"先快速跑起来,以后再说"。我这次反其道而行。前两天什么都没装,只是反复擦掉重做几遍 shell 启动配置,直到 time zsh -i -c exit 稳定在 80ms 以下、time zsh -c exit 稳定在 20ms 以下,之后才开始迁移项目。
这种"先把基础打牢"的耐心,在一两天的时间尺度上看是浪费,在一年两年的时间尺度上看是回报最高的投资。M5 Max 跑得多快不重要,我每天打开终端那 100 ms 才是天天碰到的东西。
仓库就不放链接了,dotfiles 里有一些和我个人 / 工作相关的东西不便公开。但这篇文章里的所有配置片段都可以直接拿去用,自由组合。
下篇可能写一下 1Password CLI(op)在脚本里的妙用,以及如何用 chezmoi 把这一整套 dotfiles 真正自动化部署到新机器。立 flag,有空再写。