把 Codex Home 迁移做成跨平台 Node 脚本
整理 Codex Home Git 同步时,第一版迁移命令很自然地写成了两套:macOS / Linux 用 ln -s,Windows 用 PowerShell 处理 symbolic link、junction 和 hard link。
这能把事情做成,但文章读起来会让人先选平台,再选命令。对一次迁移来说还好;对一个要长期在多台设备之间复用的工作流来说,平台判断最好进入脚本内部。读者真正需要记住的是同一个入口:
node tools/setup-codex-home.mjs --repo "$HOME/code/codex-home" --dry-run
node tools/setup-codex-home.mjs --repo "$HOME/code/codex-home"Windows PowerShell 里仍然是这支脚本:
node .\tools\setup-codex-home.mjs --repo "$HOME\Desktop\code\codex-home" --dry-run
node .\tools\setup-codex-home.mjs --repo "$HOME\Desktop\code\codex-home"跨平台的意思是读者面对同一个脚本入口,脚本在不同系统上选择合适的文件系统能力。
先定迁移边界
这个脚本解决的是一个很窄的问题:把私有仓库里的可迁移资产挂回 Codex 的运行目录。
~/.codex/AGENTS.md -> codex-home/global/AGENTS.md
~/.codex/agents_references -> codex-home/agents_references
~/.codex/rules -> codex-home/rules
~/.codex/skills -> codex-home/skills
~/.codex/env -> codex-home/env
~/.codex/tools -> codex-home/tools它不搬 auth.json、sessions/、浏览器 profile、sqlite、插件缓存和日志。这些内容属于当前机器的运行现场,新机器上应该由 Codex 自己重新生成。
边界定窄以后,脚本就不需要理解 Codex 的全部状态。它只需要做路径检查、备份旧入口、创建链接和打印验证结果。
链接原语不能混成一个概念
迁移脚本看起来只是在做「链接」,不同系统里的链接却不是同一种东西。
| 平台 / 对象 | 推荐动作 | 原因 |
|---|---|---|
| macOS / Linux 文件 | symbolic link | POSIX 语义直接,权限和工具链都熟悉。 |
| macOS / Linux 目录 | symbolic link | 目录 symlink 是常规能力,ln -s 就能表达。 |
| Windows 目录 | junction | 目录 junction 更贴近 Windows 常见无管理员迁移场景。 |
| Windows 文件 | symbolic link,失败后同盘 hard link | 文件 symlink 可能受权限或开发者模式影响;hard link 可以作为同一 NTFS 卷内的兜底。 |
| WSL | 按 Linux 处理 | WSL2 是独立 Linux 环境,有自己的 ~/.codex 和文件系统语义。 |
这里最容易误判的是 Git Bash。Git Bash 能让很多类 Unix 命令在 Windows 里跑起来,但它没有把 NTFS 变成 POSIX 文件系统。ln -s 最后创建什么,仍然取决于 Windows 权限、MSYS 配置和目标类型。迁移脚本要长期复用时,把这层判断写进代码,比把一段 shell 交给兼容层更清楚。
为什么选 Node
这个场景里,Node 的优势落在标准库上:它刚好能直接表达问题。
node:path 和 node:os 负责路径和 home 目录,node:util.parseArgs 负责参数,node:fs/promises 负责文件系统动作。脚本真正依赖的是 fsPromises.symlink(target, path, type):它在 Windows 上接受 file、dir、junction,在其他平台上忽略这个类型参数。
目录链接的关键分支可以短到这样:
const type = process.platform === 'win32' ? 'junction' : 'dir';
await symlink(targetPath, linkPath, type);文件链接则多一层 fallback:
try {
const type = process.platform === 'win32' ? 'file' : undefined;
await symlink(targetPath, linkPath, type);
} catch (error) {
if (process.platform !== 'win32') {
throw error;
}
await link(targetPath, linkPath);
}Python 也能做很多跨平台路径处理,pathlib、os.symlink、os.link 都可用。问题在于 Windows junction 不是 Python 标准库里一个同等直接的参数;要完整覆盖,通常要调用 Windows 命令、PowerShell 或 Win32 API。PowerShell 处理 Windows 链接很强,但到了 macOS / Linux 又会变成另一套入口。
这个脚本只是仓库里的本地迁移工具,不值得引入完整 CLI 框架,也不需要 TypeScript 构建链路。零依赖 .mjs 文件已经够用:读者 clone 仓库后,只要本机有 Node,就能先 dry-run,再真实执行。
用声明式映射驱动流程
脚本的核心先是一张映射表。要挂载的资产、目标路径和对象类型都收在这里:
const linkSpecs = [
{ kind: 'file', name: 'AGENTS.md', target: path.join('global', 'AGENTS.md'), required: true },
{ kind: 'dir', name: 'agents_references', target: 'agents_references' },
{ kind: 'dir', name: 'rules', target: 'rules' },
{ kind: 'dir', name: 'skills', target: 'skills', preserveSystemSkills: true },
{ kind: 'dir', name: 'env', target: 'env' },
{ kind: 'dir', name: 'tools', target: 'tools' },
];有了这张表,主流程就能保持稳定:
- 目标文件不存在时,必需项直接报错,可选目录跳过。
- 目标链接已经正确时,打印
exists并跳过。 - 目标位置已有普通文件、普通目录或错误链接时,先改名成
.local-backup.<timestamp>。 - 根据平台和对象类型创建 symlink、junction 或 hard link。
- 最后再跑一次 verify,确认链接能解析到仓库里的目标。
这个结构比把六条链接命令展开在三个平台小节里更容易维护。以后新增 templates/ 或 prompts/,只需要加一条映射;平台分支仍然留在统一的创建函数里。
幂等比命令短更重要
迁移脚本会改用户 home 目录,短命令不是第一目标,能重复执行才是第一目标。
--dry-run 是第一层保护。新机器上先看脚本准备做什么,再决定要不要真实执行。它应该明确打印即将创建的链接、即将备份的旧路径和会跳过的缺失目录。
备份是第二层保护。已有 ~/.codex/AGENTS.md、~/.codex/skills 或 ~/.codex/rules 时,脚本不直接覆盖,而是改名成 .local-backup.<timestamp>。新机器如果已经写过自己的 Skill 或规则,后面还能从备份目录里合并。
.system/ 是第三个边界。Codex 可能会在 ~/.codex/skills/.system/ 下生成系统 Skill;整个 skills/ 链到仓库后,.system/ 会出现在仓库工作区里。脚本可以在链接前保留这个目录,仓库再用 .gitignore 忽略它。这样 Codex 能继续读系统 Skill,Git 也不会把运行态收进去。
还有一条更朴素的规则:脚本只移动自己负责的几个入口,不扫描整个 ~/.codex,也不删除 auth.json、session、sqlite、cache 或浏览器 profile。迁移资产和清理运行态是两件事,放在同一个脚本里会扩大风险面。
Skill 脚本也要迁走
后面继续整理 Codex Home 时,另一个容易漏掉的边界浮出来:skills/ 下面每个 Skill 自带的小工具也会跟着仓库走。Skill 本身会随仓库同步到 Windows、macOS、Linux 或 WSL,里面如果藏着 .sh、PowerShell 示例、here-doc、${VAR:-...} 这类 shell 方言,新机器上迟早还要再修一轮。
处理这类脚本时,我现在默认把可复用逻辑放进 scripts/*.mjs 或明确依赖的 Python helper。Skill 文档只保留稳定入口和环境检查,例如先确认当前机器有没有 node、npx、目标 CLI、浏览器扩展或登录态;平台差异写进 helper 内部,文档里不要展开成「macOS 一段 Bash、Windows 一段 PowerShell」。如果某个能力天然只适合某个平台,比如调用 macOS 系统剪贴板或 Windows 注册表,也要把平台边界写清楚,避免读者以为它是通用入口。
OpenCLI、Playwright 这类工具尤其适合这么处理。Windows 下 npx.cmd、npm.cmd 和项目内 .cmd shim 会经过一层命令解析;长 JS 如果写成 node -e 或 npx ... -e,引号、换行和 $ 都可能被 shell 先处理一遍。我在 Windows 上 Node spawn 命令为什么会 ENOENT里专门记过这类问题。放回 Skill 设计里,规则就很直接:长逻辑写成真实文件,由 Node helper 读入、传参和执行,命令行只负责启动这个文件。
Skill 自检也应该扫这一层。除了跑 node --check、Skill 校验和 markdown 检查,我会顺手看仓库里是否还残留平台专用脚本或容易被 shell 二次解析的写法:
rg --files skills | rg '(\.sh$|\.ps1$|\.cmd$|\.bat$)'
rg -n 'PowerShell|node -e|npm exec|npx .* -e|\$\{[A-Za-z_][A-Za-z0-9_]*:-|\$\(' skills -g 'SKILL.md'这不是说仓库里永远不能出现平台专用命令。更准确的边界是:默认入口尽量跨平台,平台专用能力必须被标注为平台专用;复杂脚本放进可检查、可测试、可复用的文件,不散在 Markdown 命令块里。
WSL 当成另一台 Linux 机器
WSL2 适合 Linux-native 工具链,尤其是项目、包管理器、Docker/Linux 命令和线上环境都贴近 Linux 的场景。Codex 的 WSL 文档也建议把仓库放在 WSL 的 home 目录下,避免 /mnt/c/... 带来的性能和权限问题。
所以 WSL 里的迁移方式按 Linux 处理:
mkdir -p ~/code
git clone <private-repo-url> ~/code/codex-home
cd ~/code/codex-home
node tools/setup-codex-home.mjs --repo "$PWD" --dry-run
node tools/setup-codex-home.mjs --repo "$PWD"Windows 原生 Codex 和 WSL Codex 各自有自己的 ~/.codex。如果两边都要使用同一套配置,我更愿意让它们各自 clone 同一个私有仓库,再通过 Git 同步,而不是让两个运行目录互相指。这样 Windows 的登录态、浏览器 profile 和插件缓存留在 Windows;WSL 的运行态留在 Linux;共享的只有仓库里的可迁移资产。
一个入口处理平台差异
这次选型留下的规则很简单:
- 跨平台迁移优先做成一个脚本入口,平台差异收进实现。
- 文件系统动作优先用标准库;标准库能表达
junction这种平台差异时,不要额外引入依赖。 - 会改 home 目录的脚本必须支持 dry-run、备份、跳过正确链接和最终验证。
- Windows、macOS、Linux 可以共享资产仓库,但运行态留在各自环境。
- WSL 按 Linux 机器看待,不和 Windows 的
~/.codex共享运行目录。
跨平台脚本把差异固定在少数函数里。读者面对的是一个稳定入口,脚本内部负责尊重每个系统真实的文件系统规则。