跨平台子进程封装推荐:Peri Code shell_command统一三处判断
Peri Code 的跨平台子进程封装——一个 shell_command() 统一三处手写平台判断
Windows 用户配置 MCP 服务端时,通常会填写 "command": "npx"。在 macOS 上运行毫无问题,一旦切换到 Windows,Agent 立刻报错「找不到可执行文件」——which npx 明明能定位到路径,但 tokio::process::Command::new("npx") 在 Windows 上死活启动不起来。
先拆解这个尴尬场景:不仅仅是 MCP 场景会撞上,任何需要通过 shell 启动子进程的代码,都有可能掉进同一个坑里。Peri 项目里三个模块各自 spawn 子进程,最终只有一处处理正确。问题出在哪里?
npx 在 Windows 上启动不了
npx 在 Windows 上并非真正的可执行文件。它本质上是一个 .cmd 批处理脚本,shell 能识别它,但操作系统的 Process API 不认识。Command::new 底层调用 CreateProcess,而 CreateProcess 不会解析 .cmd——必须先启动 cmd.exe,由它来执行解析工作。
Windows 内核只认可一种可执行格式:PE。CreateProcess 拿着 npx 去 PATH 里查找 npx.exe,找不到就直接报错。.cmd 不是 PE,内核根本不管。那为什么在命令行里直接敲 npx 就能跑?答案在于 cmd.exe 自己补了一刀——PATHEXT 环境变量里列举了一串后缀(.COM;.EXE;.BAT;.CMD),cmd.exe 按这个列表逐个拼接 npx.com、npx.exe、npx.bat、npx.cmd,找到哪个用哪个。这套匹配是 shell 完成的,不是内核完成的。跳过 shell 直接调用 CreateProcess("npx"),匹配不生效,直接崩溃。
macOS 和 Linux 则完全不同。内核的 execve 会读取文件头——如果第一行是 #!/usr/bin/env node,内核自动找到 node,把脚本交给它执行。Command::new("npx") 在 macOS 上能跑,正是因为它是带 shebang 的 Node.js 脚本,内核代劳了解释器链路。
但内核只处理解释器,不处理 shell。管道、重定向、$VAR 展开、type 等内建命令——全都不在 shebang 的能力范围内。所以 Unix 上统一走 bash -c,不是画蛇添足——而是让所有 spawn 行为保持一致,不区分「shebang 能解决的」和「shebang 解决不了的」。
两套内核,两个 shell——Windows 上 cmd /C npx,非 Windows 上 bash -c 。一个函数收住。
三个模块,三种写法
MCP 客户端在 spawn_stdio_transport() 里直接拿用户配置的 command 字符串构造 Command::new(command),后面接 args 和 env,不做任何平台判断。配置里写 npx,Windows 上 CreateProcess 不认识——直接崩。
Bash 工具倒是自己做了一份平台切换——用 cfg! 编译期判断当前平台,Windows 上起 cmd /C,非 Windows 上起 bash -c。能跑,但这段判断是 Bash 工具内部的私有逻辑,MCP 客户端和 Hook 执行器用不上。
Hook 执行器的做法又不同——它默认用 bash 构造 Command::new("bash").arg("-c")。shell 的值可以配置,但默认就是 bash,Windows 上没有这个程序。即使想办法绕过去,Hook 执行器还要额外处理 Unix 侧 bash -c 的参数拼接——引用和转义。
三种写法,三种不同程度的失灵。MCP 客户端完全没处理,Hook 执行器处理错了,只有 Bash 工具是对的——但这套逻辑锁在 Bash 工具里,没人能复用它。
一个函数,两套 shell
正确的做法是把平台判断从三个模块里抽出来,做成一个独立的模块。新建 peri-middlewares/src/process/mod.rs,对外只暴露一个 shell_command(command, args) 函数——它不做 spawn,只返回一个配置好的 tokio::process::Command。调用者拿到后自己配 env、cwd、stdio,和直接用 Command::new 一样的用法。
函数内部的逻辑是简单的平台路由——编译期用 cfg! 判断 target_os。Windows 分支构造 cmd /C ,命令和每个参数作为独立 argv 传给 cmd.exe。非 Windows 分支构造 bash -c ",因为 bash -c 只接收一个字符串作为脚本,所以需要把命令和参数拼成一行——如果参数里含空格、引号或反斜杠,用单引号包裹并转义内部单引号。
只是编译期二分,不做运行时检测。不自动识别 .cmd 后缀,不引入第三方异步 crate,所有 spawn 场景一律走 shell 包裹。
三处调用改成一行
三条调用路径收敛成同一个函数——MCP 客户端把 Command::new(command) 替换为 crate::process::shell_command(command, &args),用户配置里的 command: "npx" 不用改,Windows 上直接能跑。Bash 工具删掉那份手写的 cfg! 判断,换成同一个 shell_command() 调用。Hook 执行器(shell 配置字段保留但被忽略,向后兼容已有的 Hook 配置)不再假设系统有 bash,全部交给 shell_command()。之后任何模块新增子进程 spawn,复用 shell_command(),不再手写平台判断。这条规则写进了 CLAUDE.md 的 [TRAP] 条目——所有子进程 spawn 必须通过 shell_command() 统一 wrapper,新增 spawn 时必须复用。
回过头看,这个函数做的事很少——23 行代码,不做 spawn,不做超时,不做 stdout 管道。它只解决一个问题——不管什么平台,同一套调用,拿到一个能正确启动的 Command。下次在 Windows 上配好 MCP、Agent 说 command: npx 的时候,它能启动——不需要记得这套平台逻辑。
