让 Bash Hook 跨发行版可移植:get-shit-done 社区钩子的 `!/usr/bin/env bash` 修复深度解析
在 NixOS、极简 Alpine 镜像等不按传统布局放置 bash 的发行版上,一段看似无关紧要的 shebang(#!/bin/bash)会让整个 Hook 机制静默失效。get-shit-done 通过一次发布记录于 .changeset/portable-bash-shebang-hooks.md 的修复(PR #3194),将三个可选启用的社区 .sh Hook 统一切换为 #!/usr/bin/env bash,并从安装器注释层面纠正了“POSIX 保证 PATH 中有 bash”这一错误依据。阅读本文,你将理解 shebang 与“以 bash 为运行器调用脚本”两种执行路径的本质差异,掌握跨发行版可移植 Hook 的编写约定,并了解 get-shit-done 安装器如何接线这些钩子、为何该缺陷在默认路径下长期潜伏。
一、问题背景:三个社区 .sh Hook 是什么
get-shit-done 仓库在 hooks 目录下维护了一批面向 Claude Code 的钩子脚本。其中三个基于 Bash 的钩子属于“可选(OPT-IN)”的社区功能,需要用户在 .planning/config.json 中显式开启 "hooks": { "community": true } 才会生效(钩子自身也会先读取该配置,未开启时直接静默退出):
| 钩子文件 | 挂载点 | 职责 |
|---|---|---|
| hooks/gsd-phase-boundary.sh | PostToolUse | 检测 .planning/ 规划文件的写入,在工作流外修改规划文件时输出提醒 |
| hooks/gsd-session-state.sh | SessionStart | 每次会话启动时注入项目状态提醒(输出 STATE.md 头部) |
| hooks/gsd-validate-commit.sh | PreToolUse | 校验 git commit 消息是否符合 Conventional Commits 规范,不合规则退出码 2 阻断 |
这三个脚本都遵循同一套脚本风格:用 Node.js(而非 jq)解析 JSON 事件负载,因为“在 GSD 项目中 Node 总是可用的”;同时通过 hooks/lib/git-cmd.js 之类的共享库做命令分类,规避朴素正则的漏判。本次修复涉及的就是这三个文件的第一行。
二、缺陷根因:POSIX 只保证 /bin/sh,不保证 /bin/bash
修复前,这三个脚本以 #!/bin/bash 开头。它依赖一个默认假设:bash 一定安装于 /bin/bash。
#!/bin/bash 是绝对路径 shebang,内核在 execve 时直接按字面路径去加载解释器。当目标机器上 bash 并不存在于该绝对位置时,直接执行脚本会立即失败。而这样的发行版和运行环境真实存在:
- NixOS:包管理器将软件安装进
/nix/store的哈希路径,/bin/bash默认并不存在; - 极简 Alpine 镜像:Alpine 默认 shell 是 BusyBox ash(位于
/bin/sh),bash 需另行安装且不一定落在/bin/bash; - 部分容器运行时 / 精简 Dockerfile:同样只保证最小 POSIX 布局。
从规范层面看,POSIX 标准只保证 /bin/sh 存在,从不保证 /bin/bash 存在。因此只要脚本可能被“直接执行”,#!/bin/bash 就是不可移植的写法。
三、修复方案:与仓库既有约定对齐的 #!/usr/bin/env bash
修复内容本身只有一行级改动:三个社区 Hook 的 shebang 统一改为
#!/usr/bin/env bash
/usr/bin/env 同样是绝对路径,但它几乎在所有 Unix 系系统上都存在(POSIX 亦要求 /usr/bin/env 可用),其职责是在当前 PATH 中查找 bash 并加载第一个命中的解释器。这样:
- 在 bash 位于
/usr/bin/bash、/usr/local/bin/bash或经 NixOS 的~/.nix-profile/bin注入 PATH 时都能正确解析; - 她本应成为的语义是“我要 bash,且要在运行环境的 PATH 中找它”,而非“我硬编码要求 bash 必须住在
/bin”。
更关键的是,这一约定并不是本次新发明的:仓库 scripts 下的 shell 脚本早已统一采用 #!/usr/bin/env bash,例如 scripts/base64-scan.sh、scripts/prompt-injection-scan.sh。本次修复实际上是让 hooks 目录向既有约定看齐,消除两套 shebang 风格的漂移。事实上 hooks 目录内部已经存在同款先例——hooks/gsd-graphify-update.sh 与其依赖的 hooks/lib/gsd-graphify-rebuild.sh 一开始就是 #!/usr/bin/env bash,本次修复使三个社区钩子与这些兄弟脚本保持一致。
发布侧,该改动以 type: Fixed、pr: 3194 的记录进入变更集,并在 docs/RELEASE-v1.41.0.md 的 v1.41.0 发布说明中以“Community .sh hooks use #!/usr/bin/env bash”条目对外公示。
四、为什么默认安装路径下该缺陷是“潜伏”的
本次变更集特别点出一个容易让开发者困惑的现象:在 get-shit-done 的默认安装路径下,这个 bug 并不会立刻爆发。原因在于 Claude Code 的接线方式。
看 bin/install.js 中的 buildHookCommand 函数(定义于 L1138-L1176)。它的职责是构造最终写入 settings.json 的 hook 命令,核心逻辑是:
function buildHookCommand(configDir, hookName, opts) {
if (!opts) opts = {};
// POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
// but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
// ...
const nodeRunner = resolveNodeRunner();
const runner = hookName.endsWith('.sh') ? resolveBashRunner(opts) : nodeRunner;
// Runner resolvers return null when the executable path is unavailable.
// Fall through with null so callers can skip registration with a warning
// instead of emitting a command that recreates the original hook failure.
if (runner === null) return null;
// ...
}
关键点在于:对于 .sh 后缀的钩子,安装器并不是让脚本靠自身 shebang 去启动,而是显式以 bash <路径> 的方式调用——把脚本作为 bash 的一个参数传入。此时脚本第一行的 shebang 对内核而言只是一行注释,真正被 PATH 解析的是命令行里的裸 bash。只要系统 PATH 中存在 bash(绝大多数发行版都有),这条命令就能正常工作,脚本自身的 #!/bin/bash 是否正确根本不参与执行。
这正是变更集把它定性为 latent(潜伏性缺陷) 的原因:在 Claude Code 默认的 bash <path> 接线方式下,它被完全掩盖。但一旦脚本被绕过安装器直接执行,缺陷立刻显形:
- 测试场景:像 tests/sh-hook-paths.test.cjs 这类回归测试,若直接 chmod +x 后运行钩子验证行为,就会在 NixOS/Alpine 上得到
No such file or directory; - 未来安装器变更:如果后续某版本把钩子改成直接以绝对脚本路径 spawn(不经
bash前缀),那么此前被掩盖的问题会在所有受影响发行版上集中爆发; - 手动调试:用户自己
./hooks/gsd-validate-commit.sh跑一遍排查问题时,得到的是解释器缺失的报错而非业务逻辑错误,极具误导性。
修复的意义因此在于消除一个埋在未来路径下的地雷,而非修补一条当前已坏的默认链路。
五、安装器注释的纠正:POSIX 路径保证是错误依据
变更集还提到一处注释级修正:buildHookCommand 顶部原有注释以“POSIX 保证标准 PATH”作为裸 bash 可用的依据,这一理由并不成立。本次更新后的注释(见 bin/install.js#L1140-L1147)明确了两件事:
.sh钩子运行器是 PATH 解析的裸bash:POSIX 保证的是/bin/sh,因此不能把“存在 bash”当作系统标配,这也是为什么需要resolveBashRunner去显式探测可用的 bash 路径;.js钩子与.sh钩子的运行器解析策略必须不同:.js钩子仍需要绝对化的 node 路径(resolveNodeRunner),因为 GUI 启动的运行时进程 PATH 极简,可能不包含 nvm/Homebrew/Volta 注入的 node 二进制目录(对应 issue #2979)。也就是说,“解释器是否经 PATH 解析”取决于运行器类型,不能一概而论。
从源码结构还可以推断,resolveBashRunner 的候选解析逻辑支持通过 GSD_BASH_PATH 环境变量注入自定义 bash 路径(见 bin/install.js#L718),并针对 Windows 下从 PowerShell/cmd 环境启动、裸 bash 不在 PATH 的场景显式解析 Git Bash;解析不到时会返回 null,调用方选择跳过注册并告警,而不是安装一条注定失败的钩子——这与本次 shebang 修复共享同一个设计哲学:宁可放弃注册,也不要装下一个已知会坏的配置。
六、回归保障与工程启示
从源码确认修复现状
仓库当前状态可作为修复落地的实证:
- 三个社区钩子 hooks/gsd-phase-boundary.sh、hooks/gsd-session-state.sh、hooks/gsd-validate-commit.sh 的第一行均为
#!/usr/bin/env bash; - 同目录的 hooks/gsd-graphify-update.sh 与 hooks/lib/gsd-graphify-rebuild.sh 本就采用该写法,风格统一;
- 既有回归测试 tests/sh-hook-paths.test.cjs 覆盖了
.sh钩子路径的绝对化与加引号(bug #2045/#2046),验证buildHookCommand对.sh文件名正确分支到 bash 运行器,是本次改动所在的同一代码路径的守护网。
给 Hook 开发者的三条可移植性准则
- shebang 首选
#!/usr/bin/env bash,永远别写#!/bin/bash:后者假设解释器的绝对安装位置,只在内核直启脚本时才生效;前者让解释器跟随运行环境 PATH,兼容 NixOS、Alpine 与容器场景; - 区分“解释器接线”与“脚本直启”两种执行模型:当宿主(如 Claude Code)以
bash <path>显式调用时,shebang 退化为注释,问题被掩盖;凡是可能被直接执行、被测试直接调用的脚本,shebang 就必须从一开始就正确; - 不要把 POSIX 的“最小编排”承诺当充分条件:POSIX 只保证
/bin/sh;声称“bash 可用”就必须给出可探测、可配置的解析路径(如GSD_BASH_PATH这类注入点),解析不到时优雅跳过而非注册坏配置。
七、小结
一次 one-line 的 shebang 变更,背后是一整套关于解释器解析机制的工程判断:get-shit-done 将三个社区 .sh 钩子从“硬编码 /bin/bash”迁移到“PATH 解析的 /usr/bin/env bash”,与 scripts 目录既有约定对齐;同时借安装器注释修正,把“.sh 钩子由 PATH 解析的裸 bash 启动、.js 钩子仍需绝对 node 路径”的接线语义固化下来,消除了未来安装器变更可能引爆的跨发行版隐患。理解这条修复链,等于同时掌握了 Claude Code hook 的注册机制、POSIX shebang 的边界,以及可移植 shell 脚本的编写纪律。
<输出文章>
在 NixOS、极简 Alpine 镜像等不按传统布局放置 bash 的发行版上,一段看似无关紧要的 shebang(#!/bin/bash)会让整个 Hook 机制静默失效。get-shit-done 通过一次记录于 .changeset/portable-bash-shebang-hooks.md 的修复(PR #3194),将三个可选启用的社区 .sh Hook 统一切换为 #!/usr/bin/env bash,并从安装器注释层面纠正了“POSIX 保证 PATH 中有 bash”这一错误依据。阅读本文,你将理解 shebang 与“以 bash 为运行器调用脚本”两种执行路径的本质差异,掌握跨发行版可移植 Hook 的编写约定,并了解 get-shit-done 安装器如何接线这些钩子、为何该缺陷在默认路径下长期潜伏。
一、问题背景:三个社区 .sh Hook 是什么
get-shit-done 仓库在 hooks 目录下维护了一批面向 Claude Code 的钩子脚本。其中三个基于 Bash 的钩子属于“可选(OPT-IN)”的社区功能,需要用户在 .planning/config.json 中显式开启 "hooks": { "community": true } 才会生效(钩子自身也会先读取该配置,未开启时直接静默退出):
| 钩子文件 | 挂载点 | 职责 |
|---|---|---|
| hooks/gsd-phase-boundary.sh | PostToolUse | 检测 .planning/ 规划文件的写入,在工作流外修改规划文件时输出提醒 |
| hooks/gsd-session-state.sh | SessionStart | 每次会话启动时注入项目状态提醒(输出 STATE.md 头部) |
| hooks/gsd-validate-commit.sh | PreToolUse | 校验 git commit 消息是否符合 Conventional Commits 规范,不合规则退出码 2 阻断 |
这三个脚本都遵循同一套脚本风格:用 Node.js(而非 jq)解析 JSON 事件负载,因为“在 GSD 项目中 Node 总是可用的”;同时通过 hooks/lib 下的共享模块做命令分类,规避朴素正则的漏判。本次修复涉及的就是这三个脚本的第一行。
二、缺陷根因:POSIX 只保证 /bin/sh,不保证 /bin/bash
修复前,这三个脚本以 #!/bin/bash 开头。它依赖一个默认假设:bash 一定安装于 /bin/bash。
#!/bin/bash 是绝对路径 shebang,内核在 execve 时直接按字面路径去加载解释器。当目标机器上 bash 并不存在于该绝对位置时,直接执行脚本会立即失败。而这样的发行版和运行环境真实存在:
- NixOS:包管理器将软件安装进
/nix/store的哈希路径,/bin/bash默认并不存在; - 极简 Alpine 镜像:Alpine 默认 shell 是 BusyBox ash(位于
/bin/sh),bash 需另行安装且不一定落在/bin/bash; - 部分容器运行时 / 精简 Dockerfile:同样只保证最小 POSIX 布局。
从规范层面看,POSIX 标准只保证 /bin/sh 存在,从不保证 /bin/bash 存在。因此只要脚本可能被“直接执行”,#!/bin/bash 就是不可移植的写法。
三、修复方案:与仓库既有约定对齐的 #!/usr/bin/env bash
修复内容本身只有一行级改动:三个社区 Hook 的 shebang 统一改为
#!/usr/bin/env bash
/usr/bin/env 同样依赖绝对路径,但它在几乎所有 Unix 系系统上都存在,其职责是在当前 PATH 中查找 bash 并加载第一个命中的解释器。这样:
- 在 bash 位于
/usr/bin/bash、/usr/local/bin/bash,或经 NixOS 的~/.nix-profile/bin注入 PATH 时都能正确解析; - 语义从“硬编码要求 bash 住在
/bin”变成“我需要 bash,并在运行环境的 PATH 中找到它”。
更关键的是,这一约定并非本次新发明:仓库 scripts 下的 shell 脚本早已统一采用 #!/usr/bin/env bash,例如 scripts/base64-scan.sh。本次修复实际上是让 hooks 目录向既有约定看齐,消除两套 shebang 风格的漂移。事实上 hooks 目录内部已有同款先例——hooks/gsd-graphify-update.sh 及其依赖的 hooks/lib/gsd-graphify-rebuild.sh 从一开始就是 #!/usr/bin/env bash,本次改动使三个社区钩子与这些兄弟脚本保持一致。
发布侧,该改动以 type: Fixed、pr: 3194 的记录进入变更集,并在 docs/RELEASE-v1.41.0.md 的 v1.41.0 发布说明中以 “Community .sh hooks use #!/usr/bin/env bash” 条目对外公示。
四、为什么默认安装路径下该缺陷是“潜伏”的
本次变更集特别点出一个容易让开发者困惑的现象:在 get-shit-done 的默认安装路径下,这个 bug 并不会立刻爆发。原因在于 Claude Code 的接线方式。
看 bin/install.js 中的 buildHookCommand 函数(定义于 L1138-L1176),其职责是构造最终写入 settings.json 的 hook 命令,核心分支如下(略作精简):
function buildHookCommand(configDir, hookName, opts) {
// POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
// but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
const nodeRunner = resolveNodeRunner();
const runner = hookName.endsWith('.sh') ? resolveBashRunner(opts) : nodeRunner;
// Runner resolvers return null when the executable path is unavailable;
// callers then skip registration with a warning instead of emitting
// a command that recreates the original hook failure.
if (runner === null) return null;
// … 拼接 $HOME-relative 或绝对路径(Windows-safe 正斜杠形式)的命令
}
关键点在于:对于 .sh 后缀的钩子,安装器并不让脚本靠自身 shebang 启动,而是显式以 bash <path> 的方式调用——把脚本作为 bash 的一个参数传入。此时脚本第一行的 shebang 对内核对(当 bash 读取脚本时)只是一行注释,真正被 PATH 解析的是命令行里的裸 bash。只要系统 PATH 中存在 bash(绝大多数发行版都有),这条命令就能正常执行,脚本自身的 #!/bin/bash 是否正确根本不参与运行。
这正是变更集将其定性为 latent(潜伏性缺陷) 的原因:在 Claude Code 默认的 bash <path> 接线方式下,它被完全掩盖。但一旦脚本被绕过安装器直接执行,缺陷立刻显形:
- 测试场景:像 tests/sh-hook-paths.test.cjs 这类回归测试若直接运行钩子文件验证行为,就会在 NixOS/Alpine 上得到解释器缺失的错误;
- 未来安装器变更:如果后续版本把钩子改成以脚本绝对路径直接 spawn(不再经
bash前缀),被掩盖的问题会在受影响发行版上集中爆发; - 手动调试:用户自行执行
./hooks/gsd-validate-commit.sh排查问题时,得到的是No such file or directory类报错而非业务逻辑信息,极具误导性。
修复的意义因此在于拆除一个埋在未来路径下的地雷,而非修补一条当前已坏的默认链路。
五、安装器注释的纠正:POSIX 路径保证是错误依据
变更集还提到一处注释级修正:buildHookCommand 顶部的原注释曾以“POSIX 保证标准 PATH”作为裸 bash 可用的依据,这一理由并不成立。更新后的注释(见 bin/install.js#L1140-L1147)明确了两件事:
.sh钩子的运行器是 PATH 解析的裸bash:POSIX 保证的是/bin/sh,不能把“存在 bash”当作系统标配,因此需要resolveBashRunner显式探测可用的 bash 路径;.js与.sh钩子的运行器解析策略必须不同:.js钩子仍需要绝对化的 node 路径(resolveNodeRunner),因为 GUI 启动的运行时进程 PATH 极简,可能不包含 nvm/Homebrew/Volta 注入的 node 二进制目录(对应 issue #2979)。也就是说,“解释器是否经 PATH 解析”取决于运行器类型,不能一概而论。
从源码结构还可以推断,resolveBashRunner 的候选解析支持通过 GSD_BASH_PATH 环境变量注入自定义 bash 路径(见 bin/install.js#L718),并针对 Windows 下从 PowerShell/cmd 环境启动、裸 bash 不在 PATH 的场景显式解析 Git Bash;解析不到时返回 null,调用方选择跳过注册并告警,而不是安装一条注定失败的钩子。这与本次 shebang 修复共享同一个设计哲学:宁可放弃注册,也不要装下一个已知会坏的配置。
六、回归保障与工程启示
从仓库源码确认修复现状
- 三个社区钩子 hooks/gsd-phase-boundary.sh、hooks/gsd-session-state.sh、hooks/gsd-validate-commit.sh 的第一行均已为
#!/usr/bin/env bash; - 同目录的 hooks/gsd-graphify-update.sh 与 hooks/lib/gsd-graphify-rebuild.sh 本就采用该写法,目录内风格统一;
- 既有回归测试 tests/sh-hook-paths.test.cjs 覆盖
.sh钩子路径的绝对化与加引号(bug #2045/#2046),验证buildHookCommand对.sh文件名正确分支到 bash 运行器——正是本次改动所处的同一代码路径的守护网。
给 Hook 开发者的三条可移植性准则
- shebang 首选
#!/usr/bin/env bash,勿写#!/bin/bash:后者假设解释器的绝对安装位置,只在脚本被内核直启时才生效;前者让解释器跟随运行环境 PATH,兼容 NixOS、Alpine 与容器场景; - 区分“解释器接线”与“脚本直启”两种执行模型:当宿主(如 Claude Code)以
bash <path>显式调用时,shebang 只是注释,缺陷会被掩盖;凡是可能被直接执行、被测试直接调用的脚本,shebang 必须从一开始就正确; - 不要把 POSIX 的最小编排承诺当充分条件:POSIX 只保证
/bin/sh;声称“bash 可用”就必须提供可探测、可配置的解析路径(如GSD_BASH_PATH这类注入点),解析不到时优雅跳过而非注册坏配置。
七、小结
一次 one-line 的 shebang 变更,背后是一整套关于解释器解析机制的工程判断:get-shit-done 将三个社区 .sh 钩子从“硬编码 /bin/bash”迁移到“PATH 解析的 /usr/bin/env bash”,与 scripts 目录既有约定对齐;同时借安装器注释修正,把“.sh 钩子由 PATH 解析的裸 bash 启动、.js 钩子仍需绝对 node 路径”的接线语义固化下来,消除了未来安装器变更可能引爆的跨发行版隐患。理解这条修复链,等于同时掌握了 Claude Code hook 的注册机制、POSIX shebang 的边界,以及可移植 shell 脚本的编写纪律。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00