
我上周刚拿到一台新开发机装完系统以后光把 Node、Java、Go、Docker 这些轮子配齐来回切窗口、找安装包、改 PATH、翻报错就折腾了大半个下午。这不是第一次了。所以第三次重复做这件事的时候我实在没忍住把整套路子抽成了一个脚本d.sh一个用纯 bash 写成的、把整个 Coding Agent 体系塞进同一个文件里的配环境 Agent。它解决的事情非常具体在一台新机器上自动完成“系统探测 → 工具检测 → 缺失安装 → 环境变量配置 → 二次验证”这条链路的全部动作。你只需要一行 curl 拿到脚本再执行./d.sh auto剩下的活儿它自己安排。这篇文章写给三拨人被配环境反复折磨的开发者、想用 bash 做点正经自动化的运维/后端、以及对“最小 Agent 实现”感到好奇的人。我会把设计思路、完整模块拆解、真实踩坑以及最后的使用效果都摊开讲。如果你只是想要一个能直接抄的文件我也把关键代码贴在对应小节里按顺序拼起来就是一个能跑的 d.sh。1. 为什么非要用 bash 做一个配环境 Agent1.1 配环境不是“装软件”而是一棵决策树很多人觉得配环境就是“下一步下一步装软件”其实不是。它本质是一个条件判断特别多的决策过程先判断这台机器是什么系统Linux、macOS 还是 Windows 下的 Git Bash判断系统里装了哪些包管理器apt、dnf、brew 还是 winget逐个检查目标工具在不在在的话版本够不够不在就安装装完还要配置 PATH、JAVA_HOME 之类的环境变量最后重新检测一遍确认刚才的动作真的有效。这五步正好对应一个 Agent 的经典循环感知探测环境、决策判断缺什么、行动安装配置、验证二次检测。所以从思路上说把配环境脚本做成一个 Agent 不是夸张而是这类任务天然适合。问题是大多数人配环境的时候是“人肉 Agent”眼睛看报错大脑查 Google手上敲命令。一次两次还好换到第三台、第四台机器或者帮团队新人配环境的时候这种重复劳动就很浪费了。1.2 为什么是 bash而不是 Python、Go 或 Ansible我在最初设计方案时不是没想过用“更正经”的语言但逐个排除后还是回到了 bash。理由其实很朴素Python 有“先有鸡还是先有蛋”的尴尬配环境的场景里新机器有可能连 Python 3 都没有或者系统自带的 Python 版本乱七八糟。你为了配环境先去配 Python 环境等于问题没解决还多了一层依赖。bash 则不一样Unix 系系统自带Git Bash 装一个就能在 Windows 上用几乎零前置条件。Go 编译出来的二进制确实方便分发但改一次逻辑就要重新编译一次对快速迭代不友好。而且配环境这个场景大部分操作就是调用系统命令用 Go 去包一层反而增加体积和复杂度。Ansible 适合批量管理几十台机器但在这台开发机上只是自用的话引入 Python 环境和 playbook 目录结构太重了。一个文件能解决的问题搞出一堆 yaml 文件反而难维护。bash 最大的优点是系统自带、语法简单、和命令行的距离最近。它的缺点——字符串处理别扭、没有原生数据结构、容易写出不可维护的“屎山”——完全可以通过结构化函数设计和命名约定来规避。说到底配环境这个任务的复杂度是有限的用一个轻量工具去做恰好合适。1.3 d.sh 的“Agent 循环”感知、决策、行动、验证真正让我觉得“这玩意儿配得上 Agent 这个名字”的是它的主循环设计。d.sh 的内部不是一坨顺序执行的命令而是把一个状态机塞了进去当前状态 初始 循环 感知收集系统信息、工具状态 决策比对目标清单找出缺失项 行动执行安装或配置动作 验证重新收集状态确认是否收敛 若还有缺失项继续循环否则退出因为没有真正的 AI 模型在跑这个 Agent 本质上是一个确定性有限状态机。它不会遇到问题就“想”出计划外的方案但它能把整个配环境流程自动化地收敛到一个可预期的结果。对配环境这个领域来说这已经够用了。我把这个循环拆成了五个子命令让每种使用需求都有对应的入口。下一部分细讲。2. d.sh 的功能边界与命令设计2.1 命令设计check、install、doctor、auto、listd.sh 采用类 git 的子命令风格一共五个命令每个命令对应一种完整的使用场景命令行为典型场景./d.sh list列出当前支持的全部工具清单刚拿到脚本先看看它能管啥./d.sh check只读检测输出每项工具的状态和版本检查一台机器还缺什么./d.sh install tool安装或配置指定工具只想补某一个缺失项./d.sh doctor tool对一个工具做深度诊断工具存在但用不了查原因./d.sh auto自动完成检测、安装、验证的完整闭环新机器一键配环境设计原则非常明确默认只读写入必须有显式意图。也就是说check永远只打印信息绝不改动系统只有输入install或auto才会真正调包管理器。这个约定很重要既能防止手滑执行了不该执行的命令也让新人敢在陌生机器上先跑一遍check看状态。doctor是我后来补的一个命令。因为实际使用中发现很多“配不上环境”的坑不是工具没装而是装了但 PATH 不对、版本冲突、符号链接断了。doctor会打印工具的安装路径、软链情况、版本号、以及 rc 文件里相关的配置一次给足排查信息。2.2 工具清单的登记方式约定优于配置bash 没有像样的数据结构所以我没有搞一个复杂的配置表而是用了一套命名约定来组织工具信息。每个工具在d.sh里对应三样东西一个数组元素表示工具名一个tool_check_xxx()函数负责检测一个tool_install_xxx()函数负责安装配置。主程序在 dispatch 的时候直接用工具名拼函数名动态调用。比如要检测 node就执行tool_check_node检测逻辑在函数里自己写。# 工具注册表 TOOLS(git curl wget node python3 go java docker) # 按名字执行检测函数 dispatch_check() { local tool$1 # 通过函数名拼接实现“多态” if declare -F tool_check_${tool} /dev/null 21; then tool_check_${tool} else log WARN 工具 ${tool} 还没有对应的检测函数 fi }我特意没有用 bash 4 的关联数组declare -A因为 macOS 自带的 bash 还是 3.2关联数组会直接报错。用“数组 命名约定”这种方式虽然老套但兼容性极好Git Bash、macOS、Linux 全都能跑。这也是单文件脚本必须考虑的妥协。新增一个工具的流程被压缩到两步在TOOLS数组里加个名字然后写两个函数。听起来简单实际用下来也确实简单。我自己从最开始的 5 个工具扩到现在 12 个没有因为新增工具而改过主逻辑。2.3 输出与日志终端友好、机器可读、事后可查脚本的输出质量和日志能力决定了工具“好不好用”。d.sh 有三层输出第一层是终端彩色输出。INFO 用绿色WARN 用黄色ERROR 用红色一眼扫过去就能看出当前卡在哪。但颜色输出有个坑当 stdout 被重定向到文件或管道时转义序列会变成乱码。所以我在日志函数里做了一次判断log() { local level$1; shift local ts ts$(date %Y-%m-%d %H:%M:%S) if [[ -t 1 ]]; then # stdout 是终端时才输出色彩 case $level in INFO) printf \033[0;32m[INFO]\033[0m %s %s\n $ts $* ;; WARN) printf \033[0;33m[WARN]\033[0m %s %s\n $ts $* ;; ERROR) printf \033[0;31m[ERROR]\033[0m %s %s\n $ts $* ;; esac else printf [%s] %s %s\n $level $ts $* fi printf [%s] %s %s\n $level $ts $* $LOG_FILE }[[ -t 1 ]]的作用是判断文件描述符 1 是否连接到终端。在终端跑就显示颜色接管道就有干净的纯文本。同时每一行都会附加时间戳追加到日志文件里日志文件路径放在${TMPDIR:-/tmp}下按时间戳命名方便事后排查。这套输出设计让我在使用时省了不少心。./d.sh check的输出可以直接grep或cut处理出问题又能去日志文件里翻完整时间线。3. 核心模块实现从探测到安装的完整链路3.1 跨平台系统探测与包管理器分发配环境 Agent 的第一步是搞清楚自己在哪。uname -s是跨平台探测的基础我用一个 case 分支把系统归成三类detect_os() { case $(uname -s) in Linux*) echo linux ;; Darwin*) echo macos ;; MINGW*|MSYS*|CYGWIN*) echo windows ;; *) echo unknown ;; esac }Windows 这里需要特别说明Git Bash 底层是 MSYS2 或 Cygwin 模拟层所以uname -s会返回MINGW64_NT之类的字符串。把它识别成windows后后面很多系统调用行为就和 Linux 不一样了比如安装命令要走 Windows 的包管理器而不是 apt。识别完系统紧接着是识别包管理器。这一步直接决定后续所有安装命令怎么发detect_pm() { case $OS in linux) if command -v apt-get /dev/null 21; then echo apt elif command -v dnf /dev/null 21; then echo dnf elif command -v yum /dev/null 21; then echo yum elif command -v pacman /dev/null 21; then echo pacman else echo unknown; fi ;; macos) if command -v brew /dev/null 21; then echo brew else echo missing-brew; fi ;; windows) if command -v winget /dev/null 21; then echo winget else echo unknown; fi ;; esac }有了OS和PM两个变量后面写安装函数就简单了。每个工具的安装函数内部做一个包管理器分发比如install_node里就是apt 系执行sudo apt-get install -y nodejs npmbrew 执行brew install nodewinget 执行winget install OpenJS.NodeJS这个分发逻辑看起来琐碎但它是全脚本机制的核心。没有它同样的功能就得写三套完整的安装路径脚本体积会膨胀一倍。3.2 检测函数command -v与版本解析检测一个工具是否安装最可靠的不是which而是command -v。which在很多系统上不是标准命令不同平台的实现行为不一致在某些 shell 环境里还会拖慢启动。command -v是 POSIX 标准自带的行为统一判定结果干净。tool_check_node() { if command -v node /dev/null 21; then local ver ver$(node --version 2/dev/null | sed s/^v//) log INFO node 已安装版本 ${ver} echo ${STATUS_FOUND} else log WARN node 未安装 echo ${STATUS_MISSING} fi }版本解析这里有个容易被忽略的点像node --version输出的是v20.11.0前面带个v。直接拿去比较版本号会出问题。我的做法是用sed s/^v//把前缀剥掉然后交给统一的版本比较函数。比较版本高低时又有个兼容性坑。GNU 的sort -V能直接比较版本号但 macOS 自带的 BSD sort 不支持-V参数。所以我在 d.sh 里写了个简单的版本字符串比较函数用按点拆分的数字逐个比彻底摆脱对sort -V的依赖。这个细节如果不处理在 macOS 上脚本会直接报invalid option -- V。3.3 安装与配置分离的实践我在设计工具函数时刻意把“安装”和“配置”拆成了两个逻辑阶段。以 Java 为例apt-get install openjdk-17-jdk装完以后还需要设置JAVA_HOME环境变量否则很多构建工具找不到 JDK。安装动作和配置动作的触发条件不同安装只发生在“命令不存在”时配置则每次都会检查 rc 文件里有没有对应条目没有才补充。配置函数的实现关键在幂等性。往~/.bashrc或~/.zshrc里追加内容之前一定要先判断内容是否已经存在否则每次跑auto都会重复插入一行越插越多。ensure_rc_line() { local rc_file$1 local line$2 if [[ -f $rc_file ]] grep -qF -- $line $rc_file 2/dev/null; then log INFO ${rc_file} 已包含配置跳过 return 0 fi printf \n# added by d.sh\n%s\n $line $rc_file log INFO 已写入 ${rc_file}: ${line} }这条grep -qF判断是整个配置模块的护城河。-F把匹配模式当作固定字符串避免JAVA_HOME里的$符号被当成正则处理从而误判或漏判。我自己在这个地方犯过两次错一次没加-F导致包含特殊字符的行永远匹配不上一次忘了判断直接追加导致 rc 文件被刷屏。3.4 环境变量持久化的处理环境变量持久化是配环境里最容易被忽视的一环。很多人装完 Java 发现java能跑但JAVA_HOME没生效这就是配置阶段没做好。d.sh 的原则是优先写用户级 rc 文件不碰系统级文件。具体做法是先探测当前用户用的是 bash 还是 zsh然后往对应的 rc 文件里写。Git Bash 环境下还要额外处理 Windows 风格的路径转换比如C:\Program Files\Java\jdk-17要转成/c/Program Files/Java/jdk-17才能被 bash 识别。写完之后脚本会明确提示用户执行source ~/.bashrc或重开终端。这个提示不能省因为一个会话里已经加载的环境变量不会因为 rc 文件改变而自动刷新很多新手在这一步会以为配置失败。3.5 auto 全流程的组装auto命令是 d.sh 的最终形态它把前面所有模块串起来。执行流程如下打印本次要处理的目标工具清单对所有工具做一轮check收集缺失项如果缺失项为空直接输出“环境已就绪”并退出逐个调用缺失工具的安装函数全部装完后再做一轮check对比前后状态输出最终摘要哪些工具搞定哪些工具仍然失败。第一次跑auto时最怕中途某个工具安装失败导致后面全乱。所以 d.sh 在循环里对每个安装函数单独捕获返回值失败就记入“失败列表”继续装下一个。全部跑完后统一报告而不是一遇到错误就退出。这个设计让脚本在一台缺很多东西的机器上也能尽量收敛——装不上的可以在最后看清单手动处理。4. 换行符、返回值与幂等性四个真实踩坑记录4.1 /bin/bash^M: bad interpreter——Git Bash 里最常见的换行符事故d.sh 在团队里流传开后出现频率最高的报错是/bin/bash^M: bad interpreter: No such file or directory。这个^M不是脚本内容问题而是文件里的换行符是 CRLF不是 Unix 标准的 LF。bash 在执行脚本时把行尾的\r当成了解释器路径的一部分自然找不到。原因几乎都出在 Windows 上。用 Visual Studio Code 或记事本编辑过脚本后保存文件被写成了 CRLF或者 Git 在 clone 仓库时因为core.autocrlf配置把 LF 自动转成了 CRLF。解决方法不复杂把文件转回 LF 就行# 命令行直接清掉行尾的 \r sed -i s/\r$// d.sh但“能修”和“不再踩”是两码事。我的建议是双管齐下在项目根目录放一个.gitattributes文件强制 shell 脚本以 LF 存储同时在编辑器里把默认换行符设成 LF。如果你只是下载了 d.sh 单文件没有仓库那就记得下载后用sed -i s/\r$// d.sh处理一遍再运行。4.2 Git Bash 的 crontab“失踪”与 PATH 的差异另一个频繁被误报的场景是-bash: crontab: command not found。很多人以为 Git Bash 是“Windows 里的 Linux”所以 Linux 上有的命令它都应该有。事实是 Git Bash 只提供了常用的 GNU 工具集crontab、systemctl、service这类系统级命令是不存在的。这对 d.sh 的设计有一个直接影响在工具检测清单里它把 cron 相关的检查标记为“可选项”而不是“必选项”。如果检测到crontab不存在只输出 WARN 而不是 ERROR。因为对 Windows 用户来说正确的做法是去任务计划程序里配定时任务而不是纠结 Git Bash 里为什么没有 crontab。这个案例给我最大的教训是跨平台脚本里对“命令是否存在”的判断必须结合平台语义。同样的命令在 Linux 上是基础设施在 Git Bash 里可能压根不属于它该管的事。4.3 set -e 与command -v的返回值冲突bash 脚本最佳实践通常建议开启set -e让任何命令失败时立即退出。但做环境检测时set -e和command -v之间存在一个隐蔽的冲突当工具不存在时command -v会返回非零状态在set -e模式下直接让脚本退出。我第一次写检测函数就栽在这上面。tool_check_node里只要 node 没装command -v node返回 1整个脚本就停了后面的安装逻辑根本没机会执行。解决办法有两种。一种是像前面代码里那样把command -v放进if的条件里bash 对if条件中的命令不启用set -eif command -v node /dev/null 21; then # 已安装分支 else # 未安装分支 fi另一种是在特定位置临时关闭set -e用完后恢复。我实际写 d.sh 时两种都用了但主力方案是第一种因为更安全不会出现“忘了恢复”导致后续一整段命令都不检查退出状态的情况。4.4 颜色输出在管道与日志文件中的兼容处理前面提过[[ -t 1 ]]这个判断。它解决的实际问题是当./d.sh check | grep node这种管道执行时如果脚本输出里带了 ANSI 颜色转义序列grep匹配到的内容会包含一堆\033[0;32m之类的垃圾字符肉眼几乎没法看。更好的处理是兼容NO_COLOR环境变量。有些终端环境或 CI 系统默认设置NO_COLOR1来关闭所有颜色输出脚本如果不认这个约定在 CI 日志里就会刷屏转义字符。所以我在颜色判断里加入了NO_COLOR检查if [[ -t 1 ]] [[ -z ${NO_COLOR:-} ]]; then # 启用颜色 else # 纯文本输出 fi4.5 重跑脚本的幂等性设计配环境脚本一定会被反复执行第一次跑到一半停了第二次补跑或者这次新增了几个工具在已有环境上再跑。如果脚本不具备幂等性第二次跑就会制造一堆重复配置。d.sh 的幂等性主要靠两个机制保证。第一是“安装前先检查状态”。auto在执行安装函数之前会先确认目标工具确实缺失。已经存在的工具直接跳过不会重新下载重新安装。第二是“配置写入前先查重”。ensure_rc_line函数用grep -qF判断目标行是否已经在文件里存在就跳过。这样无论跑多少遍rc 文件里跟 d.sh 相关的内容永远是那几行不会指数级膨胀。还有一个方向是并发保护。虽然正常不会同时跑两个 d.sh但为了防止极端情况下两个进程同时写 rc 文件我加了个简单的锁文件机制执行auto前先创建一个临时目录作为锁结束或异常退出时清理。这个锁不复杂属于“花了 5 分钟写但可能省不少麻烦”的投资。5. 实测效果从新机器到可开发状态的用时对比5.1 一次新机器配置的实测时间线上个月我在一台全新的 Ubuntu 24.04 开发机上跑了完整流程记录如下步骤手动操作估算d.sh 操作系统探测与包管理器确认5 分钟还要查命令1 秒检查 12 个工具缺少哪些10 分钟挨个敲命令3 秒安装缺失工具25 分钟找包名、装错重来10 分钟自动安装配置环境变量10 分钟不同工具不同写法5 秒二次验证5 分钟3 秒从 55 分钟压到 15 分钟左右关键是过程中不需要我盯着输出。真正省下来的是“思考下一步要做什么”的时间脚本自己知道下一步该测什么、装什么、配什么。对经常换机或者带新人的场景这个收益非常可观。那次实际跑的时候有两个工具安装失败。一个是 Docker因为新版 Docker Desktop 在 Ubuntu 上的安装方式改了包名另一个是 Go 的版本冲突。d.sh 把这两个标成失败打印出安装链接我手动处理掉以后重新跑了一遍./d.sh check状态就全部绿了。这个“部分失败但不中断”的设计在真实场景里尤其重要。如果脚本一遇错就退出前面装好的东西也会让人不安后面复跑时还得重新检测。5.2 扩展建议CI 配置检查器与团队内部分享d.sh 的使用场景不止新机器。我后来发现把它丢进 CI 里当“环境检查器”也很顺手。GitLab CI 或 GitHub Actions 的任务里加一步./d.sh check如果输出里有STATUS_MISSING就让 job 失败。这样任何一次构建跑起来之前都能先确认构建机上的工具链是完整的避免跑到一半才发现缺依赖。团队场景下d.sh 的价值还体现在“统一配置口径”上。新人入职配环境这件事传统做法是拉一个文档让新人自己跟着操作文档版本一多就乱。用 d.sh 之后所有工具的版本、安装方式和配置项都收敛在一个文件里新人只需要克隆脚本、跑auto出现解决不了的问题再找人对症下药。排障效率高很多因为脚本的输出本身就是线索。当然d.sh 的适用边界很明显它面向开发机的单机场景。如果你要管几十台服务器的环境一致性Ansible 或 Nix 是更合适的选择。工具选型不是越重越好而是匹配问题规模。d.sh 适合的是“一台一台逐台配”的人和团队。5.3 什么时候该停手d.sh 的边界与我的取舍写这个脚本的过程里我无数次想把它做得更“聪明”加上工具间的依赖关系、写一个交互式菜单、做一个内嵌的版本更新逻辑。最后都忍住了。原因是每加一个复杂特性脚本的体积和心智负担都会上一个台阶而配环境这个场景的收益却不大。我的取舍标准很简单如果一件事用三五行 bash 能描述清楚就留在 d.sh 里如果它开始需要一个配置文件、一个状态数据库、或者一个图形界面那它已经超出单文件 bash 脚本的合适边界了。目前 d.sh 稳定在 900 行左右覆盖 12 个常用工具新开发机上 80% 的环境配置需求都能覆盖剩下 20% 的手工处理场景脚本会把失败项和参考链接明明白白列出来让开发者知道下一步该干嘛。这就是我眼里一个“单文件 Coding Agent”该有的样子有感知有决策有行动有验证但始终知道自己边界在哪。工具再小能稳定解决问题的部分就是一个合格的 Agent。d.sh 不一定能帮你配完所有环境但至少能让你少折腾大半个下午。