## 零配置 装法从三步变两步:拖进应用程序 → 打开。中间那步「双击安装 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>
153 lines
6.0 KiB
Bash
Executable File
153 lines
6.0 KiB
Bash
Executable File
#!/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
|