跳转至

面向 Claude Code 的跨平台多语言钩子

Claude Code 插件需要能在 Windows、macOS 和 Linux 上工作的钩子。本文档描述了 hooks/run-hook.cmd 中使用的单一通用调度器模式。

权威来源: hooks/run-hook.cmd 是规范实现。本文档与代码不一致时,以代码为准。

问题

Claude Code 通过 shell 运行钩子命令: - macOS/Linux:bash 或 sh - 安装了 Git Bash 的 Windows:Git Bash - 未安装 Git Bash 的 Windows:PowerShell(旧版本使用 CMD.exe)

这两种 Windows 回退 shell 都无法解析我们的命令字符串:PowerShell 将开头带引号的路径视为字符串表达式,并在下一个裸词处报错;而 CMD.exe 的 /c 引用规则会在路径包含元字符(如 ()时剥掉外层引号。因此,我们的钩子声明了 "shell": "bash"(自 Claude Code 2.1.81 起支持;旧版本会忽略该键),这会强制走 Git Bash 路径;当没有 Git Bash 时,会产生一条可操作的“为 Windows 安装 Git”错误,而不是 shell 解析器失败。

这带来了几个挑战:

  1. 脚本执行:Windows CMD 无法直接执行 .sh 文件
  2. 路径格式:Windows 使用反斜杠(C:\path),Unix 使用正斜杠(/path)
  3. 环境变量:$VAR 语法在 CMD 中不生效
  4. .sh 自动前置:Windows 上的 Claude Code 会自动为路径中包含 .sh 的任何命令前置 bash —— 如果脚本带有扩展名,就会干扰调度器。

解决方案:无扩展名脚本 + 单一通用调度器

仓库使用一个通用的 run-hook.cmd 调度器来处理所有钩子。钩子脚本是无扩展名的(session-start,而不是 session-start.sh)。这是有意为之:它可以防止 Claude Code 在 Windows 上的自动检测机制在调度器命令前前置 bash 从而导致其损坏。

文件结构

hooks/
├── hooks.json          # Points to run-hook.cmd with extensionless script name
├── run-hook.cmd        # Cross-platform dispatcher (the polyglot wrapper)
└── session-start       # Actual hook logic — extensionless bash script

hooks.json

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|clear|compact",
        "hooks": [
          {
            "type": "command",
            "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
            "shell": "bash",
            "async": false
          }
        ]
      }
    ]
  }
}

路径被加上引号,是因为 ${CLAUDE_PLUGIN_ROOT} 可能包含空格。

run-hook.cmd 的高层工作原理

run-hook.cmd 是一个多语言脚本:Windows 将第一个块视为批处理命令,而 Unix shell 将该块视为无操作(no-op)的 heredoc,并在其后继续执行。

不要从本文档复制实现。修改调度器时,请直接阅读 hooks/run-hook.cmd,然后运行 tests/hooks/test-session-start.sh。

Windows(CMD.exe)上的工作原理

  1. 批处理部分验证脚本名称,并根据调度器自身的位置解析钩子目录。
  2. 它依次在四个位置查找 bash:
  3. C:\Program Files\Git\bin\bash.exe
  4. C:\Program Files (x86)\Git\bin\bash.exe
  5. %LOCALAPPDATA%\Programs\Git\bin\bash.exe(适用于 Windows 的 Git 的按用户安装),仅在设置了 LOCALAPPDATA 时使用
  6. PATH 上的 bash(MSYS2、Cygwin 或非默认 Git 安装),通过运行 %SystemRoot%\System32\where.exe $PATH:bash 找到,因此 where 和 bash 都不会从当前目录获取。WSL 启动器(System32、Sysnative 或 %LOCALAPPDATA%\Microsoft\WindowsApps 中的 bash.exe)以及无扩展名的匹配会被跳过,因为当未安装 Linux 发行版时,这些启动器会失败。
  7. 如果找到 bash,它会从钩子目录运行指定的无扩展名钩子脚本,并以该脚本的退出码退出。
  8. 如果未找到 bash,调度器会静默地以 0 退出——插件继续工作,只是跳过钩子。
  9. exit /b 使 CMD 在到达 Unix 部分之前停止。

Unix(bash/sh)上的工作原理

  1. : << 'CMDBLOCK' 在无操作命令上打开一个 heredoc。
  2. 整个 CMD 批处理块被 heredoc 消费并忽略。
  3. 在 CMDBLOCK 之后,bash 解析脚本目录,并直接 exec 指定的无扩展名脚本。

关键设计决策

决策 原因
无扩展名脚本 防止 Claude Code 在 Windows 上的 .sh 自动前置机制干扰调度器命令
不使用 -l(登录 shell) 不需要;钩子脚本应当自包含,不依赖登录 shell 的 PATH 设置
不使用 cygpath bash 直接接收 Windows 路径并正确处理;旧的 -c "..." 调用模式需要 cygpath,直接 exec 则不需要
未找到 bash 时静默退出 避免为没有安装 Git for Windows 的用户破坏插件;钩子上下文注入会被优雅地跳过

编写跨平台钩子脚本

你的钩子逻辑放在无扩展名的脚本文件中。一些可移植的模式:

应该这样做

  • 尽可能使用纯 bash 内置命令
  • 使用 $(command) 而不是反引号
  • 引用所有变量展开:"$VAR"

避免这样做

  • 在没有回退方案的情况下依赖与 PATH 相关的工具(钩子运行时没有 -l,因此不会设置登录 shell 的 PATH)
  • 给脚本添加 .sh 扩展名——这会触发 Claude Code 在 Windows 上的自动前置机制

示例:不使用外部工具进行 JSON 转义

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"
}

故障排查

“bash is not recognized”

CMD 在调度器尝试的四个位置中都找不到 bash。调度器以静默方式退出(0)而不是报错,因此该 hook 被跳过。请安装适用于 Windows 的 Git(系统范围或按用户),或确保非 WSL 的 bash 位于 PATH 中。

Hook 在 Unix 上运行,但在 Windows 上不执行任何操作

检查 hooks.json 中的脚本文件名是否没有扩展名。像 run-hook.cmd session-start.sh 这样的命令可能会触发 Claude Code 的 .sh 自动检测,从而绕过预期的 CMD 调度器路径,或者只是尝试运行一个不存在的 session-start.sh 脚本。

Hook 完全不触发

验证 hooks.json 中的 matcher 是否与你的宿主程序所发出的事件类型匹配。Claude Code 使用 startup|clear|compact;Cursor 使用 sessionStart。请查看 hooks-cursor.json 以获取 Cursor 变体。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 su/superpowers