给 Claude Code 接上 Mac 通知 + Bark 推送
起因:在 GitHub 上看到一份 Windows 下给 Claude Code 加系统通知的 gist ,PowerShell 写的,落地 Mac 用不了。索性重写一份 macOS 版,再顺手加一条 Bark 推送 —— 长任务跑起来人离开屏幕,手机也能响。
1. 思路
Claude Code 的 hook 系统暴露一组生命周期事件,对"被通知"的需求,只需要两个:
Stop—— 模型当前回合结束。任务跑完该响一声。Notification—— 模型需要确认/输入(permission prompt、问题等)。这种更紧急。
Hook 命令的 stdin 会拿到一份 JSON:cwd(项目路径)、transcript_path(JSONL 全量对话)、Notification 事件还会带 message。
设计取舍:
- 单文件 Python —— macOS 自带
python3,不再依赖 jq、curl 的 JSON 转义脏活。 - 本地通知优先
terminal-notifier—— 装了就用(可点击、有 group 去重),没装回落osascript,零依赖。 - Bark 是可选项 —— 只有当
BARK_KEY环境变量存在才推。失败静默,不影响 hook 退出码。 - 不 chmod —— 原因见末尾"坑"那节。脚本不可执行,靠
python3 path/to/notify.py调用。
2. 脚本:~/.claude/hooks/notify.py
#!/usr/bin/env python3
"""Claude Code notification hook for macOS.
Local toast (osascript or terminal-notifier) + optional Bark push.
Usage: notify.py <Stop|Notification|...>
Reads JSON from stdin (Claude Code hook payload):
cwd -> current working directory
transcript_path -> path to JSONL transcript (Stop event)
message -> notification message (Notification event)
Environment variables (all optional):
BARK_KEY Bark device key. If unset, Bark push is skipped.
BARK_SERVER Bark server (default: https://api.day.app).
BARK_GROUP Notification group (default: ClaudeCode).
BARK_SOUND Notification sound (default: minuet).
BARK_ICON Custom icon URL.
BARK_STOP_LEVEL Level for Stop events (default: passive).
BARK_NOTIFY_LEVEL Level for Notification events (default: timeSensitive).
Valid: active | timeSensitive | passive | critical.
CLAUDE_NOTIFY_OFF If set to "1", suppress all notifications.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path
TITLE_PREFIX = "ClaudeCode"
MAX_BODY_LEN = 200
def read_stdin_json() -> dict:
try:
raw = sys.stdin.read()
except Exception:
return {}
if not raw.strip():
return {}
try:
return json.loads(raw)
except Exception:
return {}
def last_assistant_text(transcript_path: str) -> str:
p = Path(transcript_path)
if not p.is_file():
return ""
try:
with p.open("r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()[-30:]
except Exception:
return ""
for line in reversed(lines):
try:
entry = json.loads(line)
except Exception:
continue
msg = entry.get("message") or {}
if msg.get("role") != "assistant":
continue
content = msg.get("content")
text = ""
if isinstance(content, str):
text = content
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
t = block.get("text", "")
if t.strip():
text = t
break
if text.strip():
return text
return ""
def truncate(s: str, n: int) -> str:
s = (s or "").strip()
if len(s) <= n:
return s
return s[: n - 1] + "…"
def send_mac_notification(title: str, body: str) -> None:
tn = shutil.which("terminal-notifier")
if tn:
try:
subprocess.run(
[tn, "-title", title, "-message", body,
"-sound", "Glass", "-group", "claude-code", "-ignoreDnD"],
check=False, timeout=5,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return
except Exception:
pass
def esc(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
script = (
f'display notification "{esc(body)}" '
f'with title "{esc(title)}" sound name "Glass"'
)
try:
subprocess.run(
["osascript", "-e", script],
check=False, timeout=5,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
pass
def send_bark_push(title: str, body: str, event: str) -> None:
key = os.environ.get("BARK_KEY", "").strip()
if not key:
return
server = os.environ.get("BARK_SERVER", "https://api.day.app").rstrip("/")
if event == "Notification":
level = os.environ.get("BARK_NOTIFY_LEVEL", "timeSensitive")
elif event == "Stop":
level = os.environ.get("BARK_STOP_LEVEL", "passive")
else:
level = "active"
payload = {
"title": title,
"body": body or " ",
"group": os.environ.get("BARK_GROUP", "ClaudeCode"),
"level": level,
"sound": os.environ.get("BARK_SOUND", "minuet"),
}
if icon := os.environ.get("BARK_ICON", "").strip():
payload["icon"] = icon
try:
req = urllib.request.Request(
f"{server}/{key}",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json; charset=utf-8"},
)
with urllib.request.urlopen(req, timeout=5) as r:
r.read()
except Exception:
pass
def main() -> int:
if os.environ.get("CLAUDE_NOTIFY_OFF") == "1":
return 0
event = sys.argv[1] if len(sys.argv) > 1 else "Stop"
data = read_stdin_json()
cwd = data.get("cwd") or ""
project = os.path.basename(cwd) if cwd else ""
if event == "Stop":
title = f"{TITLE_PREFIX} - {project}" if project else TITLE_PREFIX
body = ""
transcript = data.get("transcript_path") or ""
if transcript:
body = last_assistant_text(transcript)
if not body.strip():
body = "Task completed, please review results."
elif event == "Notification":
title = f"{TITLE_PREFIX} - Needs Attention"
if project:
title = f"{title} - {project}"
body = (data.get("message") or "").strip() \
or "Claude is waiting for your input or approval."
else:
title = TITLE_PREFIX
body = f"Event received: {event}"
body = truncate(body, MAX_BODY_LEN)
send_mac_notification(title, body)
send_bark_push(title, body, event)
return 0
if __name__ == "__main__":
sys.exit(main())
几个细节值得一提:
- 抓 transcript 倒数往前找 —— 只读最后 30 行 JSONL,倒序遍历,第一条
role == "assistant"且非空文本就用。content字段在不同 SDK 版本下可能是 string 也可能是 array,两种都处理。 - truncate 用
…单字符 —— 而不是...三个 ASCII 句号,省 2 字节给正文。 - Bark
level分场景 ——Stop默认passive(不打扰,只入推送列表),Notification默认timeSensitive(穿透专注模式)。等长任务跑完手机不必尖叫,但要确认时必须能听见。 - 失败静默 —— Bark 推不上、osascript 报错、transcript 读不到,全部吃异常返回。Hook 不应该把模型卡死。
3. 接入 ~/.claude/settings.json
把下面这块合进 settings.json,保留你原有的 env / permissions / enabledPlugins 等字段:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 /Users/you/.claude/hooks/notify.py Notification",
"timeout": 15
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 /Users/you/.claude/hooks/notify.py Stop",
"timeout": 15
}
]
}
]
}
}
matcher: "" 是匹配所有 —— Stop / Notification 这两个事件本来也没有 sub-matcher 概念。timeout: 15 秒兜底,超时不影响主流程。
路径必须绝对
hook command 的 cwd 不可预期(取决于当时 Claude 在哪个项目里)。~/.claude/hooks/notify.py 这种波浪号不会展开,必须写成 /Users/<你>/.claude/hooks/notify.py。4. 启用 Bark
Bark 是 iOS 上一个挺老的推送工具,免费版 key 在 day.app
拿。装好 app,主页能看到一段类似 https://api.day.app/abc123xyz/ 的链接 —— 中间那段就是设备 key。
把它加到 settings.json 的 env 块(hook 进程会继承):
"env": {
"BARK_KEY": "abc123xyz"
}
可选环境变量速查:
| 变量 | 默认值 | 用途 |
|---|---|---|
BARK_SERVER | https://api.day.app | 自建服务器改这个 |
BARK_GROUP | ClaudeCode | 通知分组(手机折叠用) |
BARK_SOUND | minuet | 铃声名 |
BARK_STOP_LEVEL | passive | 任务完成的等级 |
BARK_NOTIFY_LEVEL | timeSensitive | 需要确认的等级 |
CLAUDE_NOTIFY_OFF | (未设) | 设为 1 临时全关 |
5. 验证
设好 BARK_KEY 后,手动跑一发 Notification 模拟:
BARK_KEY=你的key echo '{"cwd":"/tmp","message":"test"}' \
| python3 ~/.claude/hooks/notify.py Notification
正常情况下:
- 桌面右上角弹一条 macOS 通知
- 手机 Bark 通知到达,分组是
ClaudeCode,级别是timeSensitive
如果只想看本地通知不打扰手机,把 BARK_KEY 临时去掉就行。
6. 几个坑
macOS 通知权限
osascript display notification 弹的通知归属到当前终端 app(Terminal / iTerm / Ghostty / Warp)。如果完全没声没影:系统设置 → 通知 → 找到对应终端 → 允许通知。hook 不生效? Claude Code 的 settings watcher 只盯着会话启动时已经存在的目录。如果 .claude/ 是会话开始后才创建的,settings 修改可能不会被 reload。打开一次 /hooks 菜单(会强制 reload),或者重启 Claude Code session。
chmod 被 deny 怎么办? 我的 settings 里 permissions.deny 包含 Bash(chmod:*),所以脚本不能 chmod +x。解法是 hook command 直接用 python3 path/to/notify.py 调用,不依赖文件可执行位 —— 反而更显式,少一处隐式状态。
通知正文太长? 脚本里 MAX_BODY_LEN = 200,截到 199 字符 + …。macOS 通知中心本身也会截断,但提前截能避免在 Bark 上看到一串 JSON 转义崩溃的字符。
想再省一点? Stop 事件每回合都触发,会很吵。两个解法:
- 在 Bark 那条线把
BARK_STOP_LEVEL已经设成passive—— 只入推送列表,不响。 - 想本地也不响:在
send_mac_notification里检查event == "Stop"时不发 osascript 就行(5 行改动)。
7. 可拓展方向
留给以后自己折腾:
- Telegram / Lark / Slack —— 把
send_bark_push改名send_remote_push,按REMOTE_KIND环境变量分发。Bark 只是其中一种。 - 细化 Notification 文案 —— 当前直接拿模型给的 message。可以判断
tool_name(如果将来 hook payload 加进来)做更精准的标题。 - 聚合连发 —— 短时间内多次 Stop 合并成一条(debounce 5s)。当前每次都响。
- 统计 —— 把每次 hook 触发记一行到
~/.claude/hooks/notify.log,看一周下来跑了多少回合、几次需要确认。这些数据其实挺有意思。
整套配置改一次就一直在了。每次模型响完 / 卡住等输入,桌面右上角"叮"一声,手机口袋里也跟着震一下 —— 长任务跑起来终于可以放心去倒杯水。