给 Claude Code 接上 Mac 通知 + Bark 推送

#工具 #Claude Code #macOS 共 2,489 字 约 5 分钟

起因:在 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.jsonenv 块(hook 进程会继承):

"env": {
  "BARK_KEY": "abc123xyz"
}

可选环境变量速查:

变量默认值用途
BARK_SERVERhttps://api.day.app自建服务器改这个
BARK_GROUPClaudeCode通知分组(手机折叠用)
BARK_SOUNDminuet铃声名
BARK_STOP_LEVELpassive任务完成的等级
BARK_NOTIFY_LEVELtimeSensitive需要确认的等级
CLAUDE_NOTIFY_OFF(未设)设为 1 临时全关

5. 验证

设好 BARK_KEY 后,手动跑一发 Notification 模拟:

BARK_KEY=你的key echo '{"cwd":"/tmp","message":"test"}' \
  | python3 ~/.claude/hooks/notify.py Notification

正常情况下:

  1. 桌面右上角弹一条 macOS 通知
  2. 手机 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,看一周下来跑了多少回合、几次需要确认。这些数据其实挺有意思。

整套配置改一次就一直在了。每次模型响完 / 卡住等输入,桌面右上角"叮"一声,手机口袋里也跟着震一下 —— 长任务跑起来终于可以放心去倒杯水。