superpowers 跨平台 Hooks 实战:一份 run-hook.cmd 多语言脚本如何打通 Windows、macOS 与 Linux
superpowers 插件需要在 Windows、macOS 和 Linux 三端运行相同的 Hook 逻辑,但三端可用的 shell 解析器各不相同(bash、Git Bash、PowerShell、CMD.exe)。本文以 docs/windows/polyglot-hooks.md 的"单一通用分发器(single generic dispatcher)"模式为主线,结合 hooks/run-hook.cmd 的逐行实现、hooks/hooks.json 的注册配置和 tests/hooks/test-session-start.sh 的测试断言,讲清楚这套无扩展名 Hook 脚本 + 多语言包装器的设计动机、双平台执行原理和可移植编写规范。读完后你将掌握:如何在 Claude Code 生态中编写三端通吃的 Hook 命令、为什么脚本必须去掉 .sh 扩展名,以及如何在无 bash 的 Windows 环境下优雅降级。
一、问题背景:Hook 命令必须经过 shell 解析
Claude Code 通过 shell 执行 Hook 命令,而不同平台实际可用的 shell 并不统一:
- macOS / Linux:bash 或 sh
- Windows(已安装 Git Bash):Git Bash
- Windows(未安装 Git Bash):PowerShell(旧版本曾回退到 CMD.exe)
文档明确指出,两个 Windows 回退 shell 都无法正确解析插件的命令字符串:
- PowerShell 会把命令开头的引号路径当作字符串表达式,并在遇到下一个裸词(bareword)时报错;
- CMD.exe 的
/c引号规则会在路径包含元字符(如()时剥掉外层引号——而C:\Program Files (x86)\...恰好包含(,这是真实会踩中的坑。
因此 superpowers 的 Hook 声明中显式指定了 "shell": "bash"(Claude Code 2.1.81 起支持该键,更早的版本会忽略它),强制走 Git Bash 路径;当系统没有 Git Bash 时,会得到一个可操作的"请安装 Git for Windows"错误,而不是难以定位的 shell 解析失败。
在此之上还有四个具体的跨平台障碍(均为原文档列出的挑战):
- 脚本执行:Windows CMD 无法直接执行
.sh文件; - 路径格式:Windows 用反斜杠(
C:\path),Unix 用正斜杠(/path); - 环境变量:
$VAR语法在 CMD 中无效; .sh自动前缀:Claude Code 在 Windows 上会自动给任何路径包含.sh的命令前面加上bash——如果 Hook 脚本带扩展名,这一行为会干扰分发器本身。
二、解决方案:无扩展名脚本 + 单一通用分发器
superpowers 仓库对所有 Hook 共用同一个 hooks/run-hook.cmd 分发器,而真正的 Hook 逻辑放在无扩展名的脚本中(是 session-start,而不是 session-start.sh)。这一点是刻意为之:命令字符串中不含 .sh,就能阻止 Claude Code 的 Windows 自动检测把 bash 前缀注入到分发器命令里、破坏其执行路径。
2.1 文件结构
hooks/
├── hooks.json # 指向 run-hook.cmd,且引用无扩展名脚本名
├── run-hook.cmd # 跨平台分发器(多语言包装器)
└── session-start # 真正的 Hook 逻辑 —— 无扩展名 bash 脚本
2.2 hooks.json 注册配置
hooks/hooks.json 的完整内容如下(见 hooks.json#L4-L16):
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|clear|compact",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
"shell": "bash",
"async": false
}
]
}
]
}
}
逐字段说明:
| 字段 | 取值 | 作用 |
|---|---|---|
matcher |
startup|clear|compact |
Claude Code 的 SessionStart 事件在会话启动、/clear、自动压缩三种场景触发;Cursor 则用 sessionStart(见 hooks/hooks-cursor.json) |
command |
"\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start" |
路径必须加引号,因为 ${CLAUDE_PLUGIN_ROOT} 展开后可能含空格;session-start 为第一个位置参数,指认要执行的无扩展名脚本 |
shell |
bash |
强制 Git Bash 路由,规避 PowerShell/CMD 的解析缺陷 |
async |
false |
同步执行,保证上下文注入发生在会话可用之前 |
注意命令尾部是 run-hook.cmd" session-start——脚本名不带扩展名,这正是后文测试用例专门校验的注册形态。
三、run-hook.cmd 源码级解析
文档强调 hooks/run-hook.cmd 是唯一权威实现,修改分发器时应直接阅读该文件并运行 tests/hooks/test-session-start.sh,而不是从文档抄实现。该文件是一个多语言脚本(polyglot):Windows 的 CMD 把开头视为批处理命令执行,而 Unix shell 把同一段内容当作一个无害的 here-doc 整体跳过。全文仅 46 行,逐段拆解如下。
3.1 第一行:多语言分界点
: << 'CMDBLOCK'
- 对 Unix shell 而言,
:是 no-op 内建命令,<< 'CMDBLOCK'开启一个以CMDBLOCK为定界符的 here-doc。整个 CMD 批处理块(第 2–40 行)全部作为 here-doc 的"数据"被消费并丢弃,shell 在CMDBLOCK标记之后继续执行真正的 Unix 逻辑。 - 对 CMD.exe 而言,
:开头的行是批处理的合法行(注释/标签式空操作),@echo off之后则进入纯批处理逻辑。
一行代码同时满足两种解析器,这是整个模式的核心技巧。
3.2 Windows(CMD.exe)分支
批处理部分的执行顺序(见 run-hook.cmd#L13-L39):
- 校验脚本名:
if "%~1"==""时输出run-hook.cmd: missing script name到 stderr 并exit /b 1。第一个参数是hooks.json传入的无扩展名脚本名(如session-start)。 - 解析 Hook 目录:
set "HOOK_DIR=%~dp0"取分发器自身所在目录,从而无论插件安装在何处都能定位到同目录下的 Hook 脚本。 - 按顺序尝试三个 bash 位置:
C:\Program Files\Git\bin\bash.exe(Git for Windows 64 位默认路径)C:\Program Files (x86)\Git\bin\bash.exe(32 位安装路径,注意路径里的(正是 CMD 引号陷阱的受害者)where bash检测 PATH 上的bash(覆盖 MSYS2、Cygwin 或非默认位置的 Git 安装)
- 找到 bash 后,以
"C:\...\bash.exe" "%HOOK_DIR%%~1" %2 %3 ...的形式直接执行命名脚本,并把最多 8 个额外参数(%2–%9)透传过去,然后exit /b %ERRORLEVEL%原样返回 Hook 脚本的退出码。 - 找不到任何 bash 时静默退出 0:
exit /b 0让插件在未安装 Git for Windows 的机器上继续正常工作,只是跳过上下文注入,而不是让 Hook 报错。 - 每个成功路径都以
exit /b收尾,确保 CMD 在执行到 Unix 部分之前停止。
3.3 Unix(bash/sh)分支
CMDBLOCK 标记之后只有四行(见 run-hook.cmd#L42-L46):
# Unix: run the named script directly
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_NAME="$1"
shift
exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
逻辑与 CMD 分支对称:用 $0 解析出分发器自身目录,取第一个参数为脚本名,shift 后 exec 直接替换进程执行目标脚本,其余参数经 "$@" 完整透传。注意这里不做任何 Windows 路径转换——bash 在 Git for Windows 环境下接收 Windows 路径即可正确处理,这一点正是设计决策表中"不需要 cygpath"的依据。
3.4 关键设计决策表
原文档总结了四个核心决策及其理由,与源码逐条对应:
| 决策 | 原因 | 源码对应 |
|---|---|---|
| 无扩展名脚本 | 防止 Claude Code 的 Windows .sh-auto-prepend 干扰分发器命令 |
run-hook.cmd#L7-L9 注释与 hooks.json 命令串 |
不使用 -l(登录 shell) |
无需登录 shell;Hook 脚本应自包含,不依赖登录 shell 的 PATH 配置 | 调用形如 bash "%HOOK_DIR%%~1",无 -l 参数 |
不使用 cygpath |
bash 直接接收 Windows 路径即可正确处理;cygpath 是旧版 -c "..." 调用模式的需求,直接 exec 不需要 |
Unix 分支直接 exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" |
| 无 bash 时静默退出 | 避免破坏未装 Git for Windows 用户的插件;上下文注入被优雅跳过 | exit /b 0(run-hook.cmd#L37-L39) |
四、被分发的 Hook 脚本:session-start 实例
hooks/session-start 是无扩展名分发器模式的实际受益者:Windows 上由 run-hook.cmd 找到 bash 后执行它,Unix 上由分发器的 shell 分支直接 exec 它,两端入口完全一致。它的工作是在 SessionStart 事件时把 using-superpowers 技能内容注入会话上下文,其中两个细节对理解本主题很有价值。
4.1 自定位与 JSON 转义
脚本开头用 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 推导插件根目录(session-start#L6-L8),再读取 skills/using-superpowers/SKILL.md。由于注入内容要嵌入 JSON 字符串,必须做转义。文档给出的通用可移植范例是纯 bash 内建的逐字符循环:
escape_for_json() {
local input="$1"
local output=""
local i char
for (( i=0; i<${#input}; i++ )); do
char="${input:$i:1}"
case "$char" in
$'\\') output+='\\' ;;
'"') output+='\"' ;;
$'\n') output+='\n' ;;
$'\r') output+='\r' ;;
$'\t') output+='\t' ;;
*) output+="$char" ;;
esac
done
printf '%s' "$output"
}
而仓库实际采用的是更高效的参数替换版本(session-start#L16-L24)——每个 ${s//old/new} 是单次 C 层遍历,比逐字符循环快几个数量级,源码注释明确写道它"取代了逐字符循环":
escape_for_json() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
4.2 按平台输出不同 JSON 字段
session-start 的输出口径(session-start#L38-L47)按环境变量分三路:
- 设置了
CURSOR_PLUGIN_ROOT(且 Cursor 可能同时设置CLAUDE_PLUGIN_ROOT)→ 顶层additional_context(snake_case); - 有
CLAUDE_PLUGIN_ROOT且无COPILOT_CLI→ 嵌套的hookSpecificOutput.additionalContext; - 其余(如 Copilot CLI 设置
COPILOT_CLI=1)→ SDK 标准的顶层additionalContext。
注释特别说明:Claude Code 会同时读取 additional_context 和 hookSpecificOutput 且不做去重,所以只能输出当前平台消费的那一个字段。这也是分发器必须保证 Hook 在三端都以同样的 bash 语义运行的原因——输出形状由环境变量而非平台决定。
五、测试如何验证整套分发机制
tests/hooks/test-session-start.sh 用纯 bash + node 断言覆盖了分发链路的三个关键面,修改 run-hook.cmd 后必须跑通它:
- 注册形态断言(test-session-start.sh#L146-L165):用 node 解析
hooks/hooks.json,校验两个硬约束——shell必须是"bash",且command必须以run-hook.cmd" session-start结尾(即命令中不得出现.sh后缀,防止触发 Windows 自动前缀)。这条断言的注释复述了第一节的动机:Windows 上 PowerShell 会产生 ParserError、cmd.exe 会在含元字符的路径上剥引号。 - 包装器分发断言(test-session-start.sh#L177-L185):直接以
bash hooks/run-hook.cmd session-start调用包装器(模拟 Unix 分支),断言输出是合法的嵌套hookSpecificOutputJSON 且包含预期的上下文文本——验证了第 3.3 节 shell 分支的exec透传路径。 - 平台输出形状断言:分别以
CLAUDE_PLUGIN_ROOT、CURSOR_PLUGIN_ROOT、COPILOT_CLI=1三种环境运行 Hook,验证三种 JSON 形状互斥(例如 cursor 形状下不得出现hookSpecificOutput),并用env -i清空环境变量保证测试不受本机 shell 配置污染。
此外,仓库用 scripts/lint-shell.sh 对 shell 文件做 ShellCheck 检查(默认 --severity=warning,--strict 模式追加 quote-safe-variables 等规则);其中 is_shell_file 的判定逻辑(lint-shell.sh#L26-L40)值得注意:.sh 扩展名是首要判据,无扩展名文件则回退到检查首行 shebang——session-start 正是靠 #!/usr/bin/env bash 首行被纳入 lint 范围的。
六、编写跨平台 Hook 脚本的规范
文档给出的可移植编写规则,全部可由分发器的执行环境解释:
应该做(Do):
- 尽量使用纯 bash 内建命令——Hook 通过分发器以非登录、非交互方式执行,外部工具越少越稳;
- 用
$(command)替代反引号; - 所有变量展开加引号:
"$VAR"。
避免(Avoid):
- 依赖 PATH 中的工具却不给回退——Hook 不带
-l运行,登录 shell 的 PATH 不会被设置,这正是分发器决策表中"No-l"的对应面; - 给脚本起
.sh扩展名——会触发 Claude Code 的 Windows auto-prepend,绕过分发器。
docs/porting-to-a-new-harness.md(Part 7 — Cross-platform / Windows)将这两条规则上升为移植约束:Hook 脚本必须无扩展名,且不要为每个操作系统写变体脚本——一份无扩展名 bash 脚本加多语言包装器即可覆盖全部三端。
七、故障排查
原文档列出的三种典型症状,结合源码可进一步定位:
7.1 "bash is not recognized"
CMD 在分发器尝试的三个位置(两个 Program Files 路径 + PATH)都没找到 bash。此时分发器按设计静默退出 0,Hook 被跳过而非报错。修复方式:将 Git for Windows 安装到标准路径,或确保 bash 在 PATH 上。排查时可在 CMD 中手动执行 run-hook.cmd#L21-L35 对应的 if exist 检查。
7.2 Unix 上正常、Windows 上无动作
首先检查 hooks.json 中脚本名是否无扩展名。形如 run-hook.cmd session-start.sh 的命令会触发 Claude Code 的 .sh 自动检测,绕过预期的 CMD 分发路径,或者干脆去执行一个不存在的 session-start.sh。tests/hooks/test-session-start.sh#L157-L160 的正则断言 /run-hook\.cmd" session-start$/ 就是防止这种漂移的回归测试。
7.3 Hook 完全不触发
核对 hooks.json 的 matcher 与当前 harness 实际发出的事件类型是否匹配:Claude Code 使用 startup|clear|compact 的 SessionStart,Cursor 使用小写的 sessionStart(其注册文件是 hooks/hooks-cursor.json,且省略了 matcher/type/async 字段,命令直接写 ./hooks/run-hook.cmd session-start)。
八、背景与后续修改指引
文档末尾引用了上游 Claude Code 的两个历史 issue 作为该模式动机的来源:.sh 脚本在 Windows 上会被编辑器打开(anthropics/claude-code#9758)、Hooks 在 Windows 上不工作(anthropics/claude-code#3417)。这两个问题正是"PowerShell/CMD 解析失败"和".sh 自动前缀"两个坑的历史注脚。
最后重申文档给出的维护纪律:hooks/run-hook.cmd 是唯一权威实现——修改分发器时直接阅读源码而非本文或文档转述,修改后运行 tests/hooks/test-session-start.sh 验证;若把本模式移植到新 harness,docs/porting-to-a-new-harness.md 的 Part 7 给出了必须遵守的两条规则(无扩展名、不写按 OS 区分的变体脚本)。
参考文件索引
| 文件 | 角色 |
|---|---|
| docs/windows/polyglot-hooks.md | 本文主体文档:模式动机与决策记录 |
| hooks/run-hook.cmd | 权威多语言分发器实现(46 行) |
| hooks/hooks.json | Claude Code 侧 Hook 注册(含 shell: "bash") |
| hooks/hooks-cursor.json | Cursor 侧注册变体(sessionStart 事件) |
| hooks/session-start | 无扩展名 Hook 脚本:上下文注入与 JSON 转义 |
| tests/hooks/test-session-start.sh | 分发链路的形态、透传与平台输出测试 |
| scripts/lint-shell.sh | Shell 脚本静态检查(ShellCheck + 语法检查) |
| docs/porting-to-a-new-harness.md | 移植指南 Part 7:跨平台约束 |
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 StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00