notify-setup

一键配置 Claude Code 桌面通知(Windows 弹窗 / macOS 通知中心)

Skill file

Preview skill file
---
name: claude-notify

description: >
  Desktop notifications for Claude Code.
  Support Windows Toast and macOS Notification Center.
  Popup notification when Claude Code finishes tasks
  or requires user input.

metadata:
  author: qyzxcswbll
  version: "2.0.2"

  tags:
    - claude
    - claude-code
    - notification
    - notify
    - toast
    - popup
    - desktop
    - windows
    - macos
    - hooks
---

# Claude Code Notify Setup

## 概述

安装后在 Claude Code 中执行(二选一):

- **CLI 版**(终端):输入 `/notify-setup`
- **VSCode 版**(聊天框):用户说 **「配置桌面通知」**

自动完成以下配置:

- 任务完成时弹出通知,显示你最后一条输入
- 需要你回应时弹出通知

支持 Windows 10/11 Toast 通知和 macOS 通知中心。零额外依赖。

## 执行步骤

### 1. 检测操作系统

判断当前操作系统。仅支持 Windows 和 macOS,Linux 不支持则提示退出。

### 2. 创建通知脚本

#### Windows

写入 `~/.claude/notify.ps1`(必须 `.ps1` 扩展名,PowerShell 需要)。

**编码要求**:先写 UTF-8 内容,然后执行 `[System.IO.File]::WriteAllText(path, content, [System.Text.Encoding]::UTF8)` 方法写入文件以保证 UTF-8 BOM。不使用 `Out-File` 或 `Set-Content`(它们默认用 UTF-16 或无 BOM)。

```powershell
param([string]$Event = 'stop')

# 开关检测——存在 ~/.claude/.notifymute 文件就不弹窗
if (Test-Path (Join-Path $env:USERPROFILE '.claude\.notifymute')) { exit 0 }

# 从 stdin 原始文本中提取 transcript_path
$transcriptPath = ""
try {
    if ([Console]::IsInputRedirected) {
        $raw = [Console]::In.ReadToEnd()
        if ($raw -match '"transcript_path"\s*:\s*"([^"]+)"') {
            $transcriptPath = $matches[1] -replace '\\\\', '\'
        }
    }
} catch {}

$projectName = ""
$sessionName = ""
$context = ""

if ($transcriptPath -and (Test-Path $transcriptPath)) {
    try {
        $headLines = Get-Content $transcriptPath -Encoding UTF8 -TotalCount 20
        foreach ($line in $headLines) {
            if (-not $projectName -and ($line -match '"cwd"\s*:\s*"([^"]+)"')) {
                $cwd = $matches[1] -replace '\\\\', '\'
                $projectName = Split-Path $cwd -Leaf
            }
            if (-not $sessionName -and ($line -match '"role"\s*:\s*"user"')) {
                try {
                    $msg = $line | ConvertFrom-Json
                    $content = $msg.message.content
                    if ($content -is [array]) { $content = ($content | Where-Object { $_.type -eq "text" } | Select-Object -First 1).text }
                    if ($content) {
                        $sessionName = ($content -replace "`n", " ").Trim()
                        if ($sessionName.Length -gt 5) { $sessionName = $sessionName.Substring(0, 5) }
                    }
                } catch {}
            }
            if ($projectName -and $sessionName) { break }
        }
    } catch {}

    try {
        # 从 transcript 尾部提取最后一条用户消息(跳过 tool_result 找有文字的那条)
        $tailLines = Get-Content $transcriptPath -Encoding UTF8 -Tail 100
        for ($i = $tailLines.Length - 1; $i -ge 0; $i--) {
            if ($tailLines[$i] -match '"role"\s*:\s*"user"') {
                try {
                    $msg = $tailLines[$i] | ConvertFrom-Json
                    $content = $msg.message.content
                    if ($content -is [array]) {
                        $content = ($content | Where-Object { $_.type -eq "text" } | Select-Object -First 1).text
                    }
                    if ($content) {
                        $context = ($content -replace "`n", " ").Trim()
                        if ($context.Length -gt 60) { $context = $context.Substring(0, 57) + "..." }
                        break
                    }
                } catch {}
            }
        }
    } catch {}
}

# 模式检测——优雅弹窗分流
$notifyModePath = Join-Path $env:USERPROFILE '.claude\notify-mode'
if (Test-Path $notifyModePath) {
    $mode = (Get-Content $notifyModePath -Encoding UTF8).Trim().ToLower()
    if ($mode -eq "elegant") {
        $elegantScript = Join-Path $env:USERPROFILE '.claude\notify-elegant.ps1'
        if (Test-Path $elegantScript) {
            & $elegantScript -Event $Event -ProjectName $projectName -SessionName $sessionName -Context $context
            exit 0
        }
    }
}

# XML 转义函数(防止用户输入含 <>& 导致 Toast 崩溃)
function Escape-Xml([string]$text) {
    if (-not $text) { return "" }
    return [System.Security.SecurityElement]::Escape($text)
}

# 标题:项目名(第一行)
$title = Escape-Xml $(if ($projectName) { $projectName } else { "Claude Code" })

# 副标题:会话名(第二行,带图标)
$subtitle = Escape-Xml $(if ($sessionName) { "⚙ $sessionName" } else { "" })

# 内容:第三行
$isStop = ($Event -eq 'stop')
if ($isStop) {
    $body = Escape-Xml $(if ($context) { "✨ 搞定了: $context" } else { "✨ 搞定了~" })
} else {
    $body = Escape-Xml $(if ($context) { "💬 需要你瞅一眼: $context" } else { "💬 需要你瞅一眼" })
}

# Windows Toast
try {
    Add-Type -AssemblyName System.Runtime.WindowsRuntime
    $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
    $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
    $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
    if ($subtitle) {
        $toastXml = "<?xml version=""1.0"" encoding=""utf-8""?><toast><visual><binding template=""ToastText04""><text id=""1"">$title</text><text id=""2"">$subtitle</text><text id=""3"">$body</text></binding></visual></toast>"
    } else {
        $toastXml = "<?xml version=""1.0"" encoding=""utf-8""?><toast><visual><binding template=""ToastText02""><text id=""1"">$title</text><text id=""2"">$body</text></binding></visual></toast>"
    }
    $xml.LoadXml($toastXml)
    $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
    [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Claude Code").Show($toast)
} catch {
    # 静默失败,绝不阻塞
}
exit 0
```

#### macOS

写入 `~/.claude/notify`(无扩展名)。

```bash
#!/bin/bash
EVENT=${1:-stop}

# 开关检测——存在 ~/.claude/.notifymute 文件就不弹窗
if [ -f "$HOME/.claude/.notifymute" ]; then exit 0; fi

INPUT=$(cat)

TRANSCRIPT_PATH=$(echo "$INPUT" | grep -o '"transcript_path" *: *"[^"]*"' | sed 's/"transcript_path" *: *"\(.*\)"$/\1/' | sed 's/\\\\/\//g')

PROJECT_NAME=""
SESSION_NAME=""
CONTEXT=""

# python3 检测——不存在则跳过上下文解析,直接发基础通知
if command -v python3 &> /dev/null && [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
    # 从 transcript 头部提取项目名和会话名(前 5 字)
    META=$(python3 -c "
import json, sys
path = '$TRANSCRIPT_PATH'
project = ''
session = ''
with open(path, 'r', encoding='utf-8') as f:
    for i, line in enumerate(f):
        if i >= 20:
            break
        try:
            msg = json.loads(line)
            msg_content = msg.get('message', {}) if isinstance(msg, dict) else {}
            if not project and msg.get('cwd'):
                cwd = msg.get('cwd')
                project = cwd.rstrip('\\\\').split('\\\\')[-1]
            if not session and msg_content.get('role') == 'user':
                content = msg_content.get('content', '')
                if isinstance(content, list):
                    texts = [c['text'] for c in content if c.get('type') == 'text']
                    content = texts[0] if texts else ''
                session = content.replace(chr(10), ' ').replace(chr(13), '').strip()[:5]
        except:
            pass
        if project and session:
            break
print(json.dumps({'project': project, 'session': session}))
" 2>/dev/null)

    PROJECT_NAME=$(echo "$META" | python3 -c "import sys,json; print(json.load(sys.stdin).get('project',''))" 2>/dev/null)
    SESSION_NAME=$(echo "$META" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session',''))" 2>/dev/null)

    # 从 transcript 尾部提取最后一条用户消息
    CONTEXT=$(python3 -c "
import json, sys
path = '$TRANSCRIPT_PATH'
with open(path, 'r', encoding='utf-8') as f:
    lines = f.readlines()
    for line in reversed(lines[-100:]):
        try:
            msg = json.loads(line)
            if msg.get('message', {}).get('role') != 'user':
                continue
            content = msg['message'].get('content', '')
            if isinstance(content, list):
                texts = [c['text'] for c in content if c.get('type') == 'text']
                content = texts[0] if texts else ''
            content = content.replace(chr(10), ' ').replace(chr(13), '').strip()
            if len(content) > 60:
                content = content[:57] + '...'
            print(content, end='')
            break
        except:
            pass
" 2>/dev/null)
fi

# 标题与内容组装
if [ -n "$PROJECT_NAME" ]; then TITLE="$PROJECT_NAME"; else TITLE="Claude Code"; fi
if [ -n "$SESSION_NAME" ]; then TITLE="$TITLE ⚙ $SESSION_NAME"; fi

if [ "$EVENT" = "stop" ]; then
    [ -n "$CONTEXT" ] && BODY="✨ 搞定了: $CONTEXT" || BODY="✨ 搞定了~"
else
    [ -n "$CONTEXT" ] && BODY="💬 需要你瞅一眼: $CONTEXT" || BODY="💬 需要你瞅一眼"
fi

# 使用环境变量传递给 osascript,彻底杜绝转义问题
export CLAUDE_NOTIFY_TITLE="$TITLE"
export CLAUDE_NOTIFY_BODY="$BODY"
osascript -e 'display notification (system attribute "CLAUDE_NOTIFY_BODY") with title (system attribute "CLAUDE_NOTIFY_TITLE")' 2>/dev/null
```

写完后执行 `chmod +x ~/.claude/notify`。

#### Windows 开关脚本

写入 `~/.claude/notify-toggle.ps1`(UTF-8 BOM 编码,同 notify.ps1):

```powershell
# notify-toggle.ps1 — 双击运行,一键开关 Claude Code 通知
$flagPath = Join-Path $env:USERPROFILE '.claude\.notifymute'
if (Test-Path $flagPath) {
    Remove-Item $flagPath -Force
    $body = "通知已开启 🔔"
} else {
    New-Item $flagPath -ItemType File -Force | Out-Null
    $body = "通知已关闭 🔕"
}

# XML 转义函数
function Escape-Xml([string]$text) {
    if (-not $text) { return "" }
    return [System.Security.SecurityElement]::Escape($text)
}

$bodyEscaped = Escape-Xml $body

try {
    Add-Type -AssemblyName System.Runtime.WindowsRuntime
    $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
    $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
    $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
    $toastXml = "<?xml version=""1.0"" encoding=""utf-8""?><toast><visual><binding template=""ToastText02""><text id=""1"">Claude Code 通知</text><text id=""2"">$bodyEscaped</text></binding></visual></toast>"
    $xml.LoadXml($toastXml)
    $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
    [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Claude Code").Show($toast)
} catch {
    # 静默失败,不弹窗也不阻塞
}
```

写入 `~/.claude/notify-toggle.bat`(纯 ASCII):

```batch
@echo off
powershell -ExecutionPolicy Bypass -File "%USERPROFILE%\.claude\notify-toggle.ps1"
```

#### macOS 开关脚本

写入 `~/.claude/notify-toggle.sh`,执行 `chmod +x ~/.claude/notify-toggle.sh`:

```bash
#!/bin/bash
FLAG="$HOME/.claude/.notifymute"
if [ -f "$FLAG" ]; then
    rm "$FLAG"
    BODY="通知已开启 🔔"
else
    touch "$FLAG"
    BODY="通知已关闭 🔕"
fi

# 使用环境变量传递,杜绝转义问题
export CLAUDE_TOGGLE_BODY="$BODY"
osascript -e 'display notification (system attribute "CLAUDE_TOGGLE_BODY") with title "Claude Code 通知"'
```

### 3. 配置 hooks

读取 `~/.claude/settings.json`。

- 如果文件不存在则创建设置文件
- 如果已存在 `hooks` 字段,合并 `Stop` 和 `Notification` 事件(保留已有配置)
- 如果没有 `hooks` 字段则添加

Windows hooks 配置(AI 安装时用 `$env:USERPROFILE` 展开为绝对路径后写入):

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "powershell -ExecutionPolicy Bypass -File \"<USERPROFILE>\\.claude\\notify.ps1\" -Event stop",
            "timeout": 15
          }
        ]
      }
    ],
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "powershell -ExecutionPolicy Bypass -File \"<USERPROFILE>\\.claude\\notify.ps1\" -Event notification",
            "timeout": 15
          }
        ]
      }
    ]
  }
}
```

macOS hooks 配置:

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/notify stop",
            "timeout": 15
          }
        ]
      }
    ],
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/notify notification",
            "timeout": 15
          }
        ]
      }
    ]
  }
}
```

### 4. 验证

Windows:

```powershell
powershell -ExecutionPolicy Bypass -File "$env:USERPROFILE\.claude\notify.ps1" -Event stop
```

macOS:

```bash
echo '{}' | bash ~/.claude/notify stop
```

无报错即成功。告知用户配置完成,之后每次 Claude 完成任务或需要回应时都会弹出通知。

### 5. 开关通知(可选)

临时不需要通知时,双击运行一次开关脚本,通知就会关闭。再运行一次,重新开启。

**Windows:** 双击 `%USERPROFILE%\.claude\notify-toggle.bat`,或 Win+R 运行:
```
%USERPROFILE%\.claude\notify-toggle.bat
```

**macOS:** 双击 `~/.claude/notify-toggle.sh`,或终端执行:
```bash
bash ~/.claude/notify-toggle.sh
```

## V2 优雅弹窗

### 模式切换(用户会说)
- **切换优雅弹窗** / **打开定制弹窗** — 切换为优雅弹窗模式
- **切回系统通知** — 切换为系统 Toast 模式

### 安装优雅弹窗脚本

AI 必须严格按以下步骤执行:

1. **读取并写入优雅弹窗脚本**:使用 `Read` 工具读取当前项目下的 `hooks/notify-elegant.ps1` 文件内容,然后使用 `Write` 工具将其完整写入到 `~/.claude/notify-elegant.ps1`(必须确保使用 UTF-8 BOM 编码)。
2. **读取并写入配置服务脚本**:使用 `Read` 工具读取当前项目下的 `hooks/notify-config-server.py` 文件内容,然后使用 `Write` 工具将其完整写入到 `~/.claude/notify-config-server.py`。
3. **创建主题目录**:执行 `mkdir -p ~/.claude/themes` 创建目录(如果不存在)。

### 启动配置服务
用户说「定制弹窗」时,AI 执行以下逻辑:

1. **检测服务是否已运行**:执行 `python3 -c "import socket; s=socket.socket(); s.settimeout(2); print(0 if s.connect_ex(('localhost',18765))==0 else 1)"`。如果返回 0 表示服务已在运行,跳过启动步骤。
2. **如果服务未运行**:执行 `python3 ~/.claude/notify-config-server.py &` 在后台启动配置服务。
3. **打开浏览器**:在浏览器打开 `docs/design-v2-preview.html`。
4. 告知用户在网页上选择主题、图标、上传立绘后点保存。

### 注意事项
- 模式标记存储在 `~/.claude/notify-mode`(内容为 `raw` 或 `elegant`)
- 配置存储在 `~/.claude/notify-config.json`
- 立绘图片存储在 `~/.claude/themes/character.png`
- 配置服务需要 Python 3,端口 18765

## 注意事项(给 AI 自己看)

- 通知脚本文件创建后不要删,hooks 事件会一直调用它
- Windows 脚本必须 UTF-8 BOM 编码,否则 PowerShell 5.1 解析带中文的脚本会报 `MissingEndCurlyBrace` 错误
- macOS 脚本无需 BOM,普通 UTF-8 即可
- `[Console]::In.ReadToEnd()` 只能消费 stdin 一次,重复调用返回空
- Stop 事件的 stdin 中不含 `transcript_messages` 字段,需通过 `transcript_path` 读 JSONL 文件提取用户消息
- **macOS 通知**:已使用环境变量传参,不再需要手动转义双引号
- **Windows hooks 命令路径必须用绝对路径**(`C:\Users\xxx\.claude\notify.ps1`),`%USERPROFILE%` 在 VSCode Claude Code hook 环境中不会被展开。安装时用 `$env:USERPROFILE` 展开后写入 JSON
- **修改 settings.json 时,必须使用 Node.js 或 Python 脚本进行安全的 JSON 解析和合并**,绝对不允许使用正则表达式或字符串替换,以免破坏用户原有的配置结构
- **Windows 执行策略**:如果用户提示「无法加载文件,因为在此系统上禁止运行脚本」,请在管理员 PowerShell 中执行 `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`,Hook 命令中已内置 `-ExecutionPolicy Bypass` 参数绕过

Source

Creator's repository · qyzxcswbll/claude-code-notify

View on GitHub

Security

Security checks in progress
Results will appear here once audits complete
Checked by 3 independent security firms
Does it try to trick the AI?Not yet checkedPending · Gen Agent Trust Hub
Does it sneak in hidden code?Not yet checkedPending · Socket
Does it have known bugs?Not yet checkedPending · Snyk