Files
vibe-foreman/scripts/setup-swift-hooks.sh
wuguanganandClaude Opus 5 bae4f9af86 feat: 零配置装 hook;改名 Vibe Foreman Free
## 零配置

装法从三步变两步:拖进应用程序 → 打开。中间那步「双击安装 hook.command」
没有任何用户能做的决定,漏点它的后果却是**静默的** —— app 开着、
界面正常、一条事件都收不到,看起来像"装了但没用"。

HookInstaller 在启动时自己装。四条边界,都有测试钉着:

- 只碰自己的条目:只删命令里含 `hubctl hook` / `hub-hook-` 的(我们的旧版),
  用户手写的 hook 和 permissions / env 等其它键一律原样保留
- 幂等:内容没变一个字节都不写。`.sortedKeys` 是这条的前提 ——
  不排序时 JSON 键序跟着哈希走,"没变就不写"的判断会随机失效
- 坏 JSON 不覆盖:用户可能只是手改时漏了个逗号,覆盖等于删了他的配置
- 失败不阻断启动:hook 没装好的 app 仍能显示会话,起不来的 app 什么都不是

hubctl 每次比内容再决定要不要拷 —— 升级 app 后 hook 还指着旧二进制的话,
协议一变整条链路静默失效。设置页显示状态 + 一键修复。

dmg 里那个 command 文件改名成「手工修复 hook(通常用不到)」,留作兜底。

实测:本机 settings.json 装前装后逐键对比,顶层键无增无减、
permissions 等非 hooks 值全部原样、9 类 hook 各 1 条无重复;
第二次启动 sha256 未变。

## 改名

显示名 Claude Hub → Vibe Foreman Free。

**bundle id、签名证书名、可执行文件名 ClaudeHub 全部不动** ——
换任何一个都会让已授予的 TCC 权限(控制 iTerm 的 Automation、通知)失效,
而 `pgrep -x ClaudeHub` 那条判活约束也依赖可执行名(见 NOTES.md)。
安装时清掉旧名字那份,避免 /Applications 里同时躺着两个共用 bundle id 的 app。

530 个测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:27:28 +08:00

153 lines
6.0 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# 把 Claude Hub 的 hook 装进 ~/.claude/settings.json。
#
# 替换掉旧的 bash hook(hub-hook-stop.sh / hub-hook-permission.sh)。旧实现的问题:
# 1. 用 `tmux display-message -p '#W'` 且**没带 -t "$TMUX_PANE"**,
# 拿到的是当前 active 窗口名而不是自己所在的窗口 —— 用户切个 tab
# 通知就挂到别的项目上了;
# 2. 完全没读 stdin,session_id / cwd / last_assistant_message 全丢;
# 3. terminal-notifier 的 -activate 只能前置 iTerm,选不了具体 tab,
# 这正是「点了通知没跳到对应窗口」的直接原因;
# 4. 配的是 "async": false 且 hook 里同步 afplay,
# **每轮回复结束都被语音播放阻塞几百毫秒**。
#
# 新实现用 hubctl(Swift 二进制):读 stdin、走 Unix socket、
# Hub 没运行时立刻放行(fail-open)。
set -euo pipefail
# 这个脚本有两种跑法,路径解析必须都成立:
# 1. 从仓库里跑:`bash scripts/setup-swift-hooks.sh`
# 2. 从 dmg 里双击「安装 hook.command」—— 这时上一级是 /Volumes,
# 仓库根本不存在。所以 HUB_DIR 只是**可选**的额外查找位置,
# 真正的主来源是已安装的 /Applications/Claude Hub.app。
HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ ! -d "$HUB_DIR/HubKit" ]; then HUB_DIR=""; fi
SETTINGS="$HOME/.claude/settings.json"
BIN_DIR="$HOME/.local/bin"
HUBCTL="$BIN_DIR/hubctl"
log() { printf '\033[36m▸\033[0m %s\n' "$1"; }
ok() { printf '\033[32m✓\033[0m %s\n' "$1"; }
warn() { printf '\033[33m!\033[0m %s\n' "$1"; }
fail() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }
# ---------- 定位 hubctl ----------
SOURCE=""
for candidate in \
"/Applications/Vibe Foreman Free.app/Contents/Helpers/hubctl" \
"/Applications/Claude Hub.app/Contents/Helpers/hubctl" \
"$HUB_DIR/HubKit/.build/release/hubctl" \
"$HUB_DIR/HubKit/.build/debug/hubctl"; do
if [ -x "$candidate" ]; then SOURCE="$candidate"; break; fi
done
if [ -z "$SOURCE" ]; then
fail "找不到 hubctl。先把 Claude Hub.app 拖进「应用程序」,或运行 bash scripts/build-swift-app.sh"
fi
mkdir -p "$BIN_DIR"
cp "$SOURCE" "$HUBCTL"
chmod +x "$HUBCTL"
ok "已安装 hubctl → $HUBCTL"
# ---------- 写 settings.json ----------
[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
cp "$SETTINGS" "$SETTINGS.bak.$(date +%Y%m%d%H%M%S)"
log "已备份 $SETTINGS"
HUBCTL="$HUBCTL" python3 - "$SETTINGS" <<'PYTHON'
import json, os, sys
path = sys.argv[1]
hubctl = os.environ["HUBCTL"]
with open(path) as handle:
settings = json.load(handle)
hooks = settings.setdefault("hooks", {})
def strip_legacy(entries):
"""移除旧的 bash hook,保留用户自己配的其它 hook。"""
kept = []
for entry in entries or []:
commands = [h.get("command", "") for h in entry.get("hooks", [])]
if any("hub-hook-" in c or "hubctl hook" in c for c in commands):
continue
kept.append(entry)
return kept
def install(event, argument, *, blocking, timeout=90):
entries = strip_legacy(hooks.get(event))
hook = {"type": "command", "command": f"{hubctl} hook {argument}"}
if blocking:
# PreToolUse 要等岛上的决策。
#
# 90 秒是超时阶梯(HookTimeouts)的最外层,必须大于 hubctl 的 75 秒读超时:
# 如果 Claude 在 hubctl 输出拒绝之前就把它杀掉,那等同于没有输出,
# 也就是**放行** —— 正好是安全默认值的反面。
hook["timeout"] = timeout
else:
# async: true 很关键 —— 旧配置是 false 且 hook 里同步 afplay,
# 导致每轮回复结束都被阻塞。
hook["async"] = True
entries.append({"hooks": [hook]})
hooks[event] = entries
# 全部九类 hook 都装上。
#
# 只装用得着的那几类是个诱人的省事做法,但它让"这一类到底通不通"无从判断 ——
# 收不到事件时分不清是"没装"还是"装了但链路断了"。全装上之后,
# 设置页的通道健康度才有意义:每一行要么亮过,要么就是真有问题。
#
# ⚠️ UserPromptSubmit / SessionStart 的 stdout 会被 Claude 当成**附加 context
# 注入进对话**。hubctl 在这些链路上一个字都不往 stdout 打,
# async: True 是第二道保险(异步 hook 的输出会被直接忽略)。
install("SessionStart", "sessionstart", blocking=False)
install("UserPromptSubmit", "userprompt", blocking=False)
install("PreToolUse", "pretool", blocking=True)
install("PostToolUse", "posttool", blocking=False)
install("Notification", "notification", blocking=False)
# Stop 是阻塞的:Hub 要在这里判断该不该把验收清单塞回去。
#
# 10 秒而不是 90 —— 它挡在**每一次收工**前面,Hub 一卡住用户就干等。
# 判断本身是查内存,毫秒级;这 10 秒纯粹是异常时的上界。
# 超时被杀掉等同于没有输出,也就是正常收工,方向是安全的。
install("Stop", "stop", blocking=True, timeout=10)
install("SubagentStop", "subagentstop", blocking=False)
install("PreCompact", "precompact", blocking=False)
install("SessionEnd", "sessionend", blocking=False)
with open(path, "w") as handle:
json.dump(settings, handle, indent=2, ensure_ascii=False)
handle.write("\n")
print("已配置 9 类 hook:SessionStart / UserPromptSubmit / PreToolUse / PostToolUse")
print(" Notification / Stop / SubagentStop / PreCompact / SessionEnd")
PYTHON
ok "已更新 $SETTINGS"
# ---------- 自检 ----------
echo
log "自检"
if "$HUBCTL" doctor; then
ok "Claude Hub 正在运行,hook 链路已通"
else
warn "Claude Hub 没在运行 —— hook 会全部放行(fail-open,不会卡住 Claude)"
echo ' 启动:open -a "Claude Hub"'
fi
echo
ok "完成。新开的 Claude 会话会自动生效;已有会话需要重启才会读到新配置。"
# 从 dmg 双击运行时终端窗口会立刻消失,看不到上面这些输出。
if [ -z "$HUB_DIR" ] && [ -t 0 ]; then
echo
read -r -p "按回车关闭…" _
fi