claude-hook-notify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # 🔔 claude-hook-notify
2
+
3
+ Claude Code 任务完成桌面通知 — 一键安装,跨平台支持 (macOS / Linux / Windows)
4
+
5
+ > 不用再盯着终端等 Claude Code 完成了。任务一完,桌面通知自动弹出。
6
+
7
+ ## 快速开始
8
+
9
+ ```bash
10
+ npx claude-hook-notify setup
11
+ ```
12
+
13
+ 就这一行。重启 Claude Code 后即可生效。
14
+
15
+ ## 效果
16
+
17
+ 当 Claude Code 完成任务时,你会收到系统原生桌面通知:
18
+
19
+ ```
20
+ ┌──────────────────────────────────────┐
21
+ │ 🔔 Claude Code 完成 (my-project) │
22
+ │ 已完成代码重构和测试 │
23
+ └──────────────────────────────────────┘
24
+ ```
25
+
26
+ API 错误中断时:
27
+
28
+ ```
29
+ ┌──────────────────────────────────────────────────┐
30
+ │ ⚠ Claude Code 错误: 请求频率限制 (my-project) │
31
+ │ Rate limit exceeded: too many requests │
32
+ └──────────────────────────────────────────────────┘
33
+ ```
34
+
35
+ ## 命令
36
+
37
+ ### 安装
38
+
39
+ ```bash
40
+ # 全局安装(所有项目生效,默认)
41
+ npx claude-hook-notify setup
42
+
43
+ # 仅当前项目
44
+ npx claude-hook-notify setup --local
45
+
46
+ # 指定监听事件
47
+ npx claude-hook-notify setup --events Stop,TaskCompleted
48
+ ```
49
+
50
+ ### 卸载
51
+
52
+ ```bash
53
+ npx claude-hook-notify uninstall
54
+
55
+ # 卸载项目级配置
56
+ npx claude-hook-notify uninstall --local
57
+ ```
58
+
59
+ ### 手动发送通知(测试用)
60
+
61
+ ```bash
62
+ # 测试通知
63
+ npx claude-hook-notify notify --event Stop --dry-run
64
+
65
+ # 自定义通知
66
+ npx claude-hook-notify notify --title "构建完成" --message "所有测试通过"
67
+ ```
68
+
69
+ ## 监听事件
70
+
71
+ | 事件 | 触发时机 | 音效 (macOS) |
72
+ | ----------------- | -------------------------------- | ------------ |
73
+ | `Stop` | Claude Code 完成一次响应 | Glass |
74
+ | `TaskCompleted` | 子任务被标记为完成 | Hero |
75
+ | `Notification` | Claude Code 需要你注意(等输入) | Ping |
76
+ | `StopFailure` | API 错误导致中断(限流/认证/服务器错误等) | Sosumi |
77
+
78
+ > **注意**: `Stop` 事件在响应因 token 限制被截断时会显示特殊提示。
79
+
80
+ 也可以添加额外事件:
81
+
82
+ ```bash
83
+ npx claude-hook-notify setup --events Stop,TaskCompleted,Notification,StopFailure,PostToolUseFailure,SubagentStop
84
+ ```
85
+
86
+ ## 平台支持
87
+
88
+ | 平台 | 通知方式 | 依赖 |
89
+ | ------- | -------------------- | --------------------------------------------- |
90
+ | macOS | `osascript` | 无(系统自带) |
91
+ | macOS | `terminal-notifier` | 可选: `brew install terminal-notifier`(更好) |
92
+ | Linux | `notify-send` | `sudo apt-get install libnotify-bin` |
93
+ | Windows | PowerShell | 无(系统自带) |
94
+
95
+ ## 原理
96
+
97
+ 安装时会在 `~/.claude/settings.json` 中添加 hooks 配置。当对应事件触发时,Claude Code 会自动执行 `npx claude-hook-notify notify --event <事件名>`,脚本会读取 hook 传入的上下文信息(任务名称、最后的 assistant 消息等),然后通过系统原生 API 发送桌面通知。
98
+
99
+ ## 编程接口
100
+
101
+ 也可以作为库使用:
102
+
103
+ ```js
104
+ const { sendNotification } = require("claude-hook-notify");
105
+
106
+ await sendNotification({
107
+ event: "Stop",
108
+ title: "构建完成",
109
+ message: "所有 42 个测试通过",
110
+ });
111
+ ```
112
+
113
+ ## 已知限制
114
+
115
+ - **Ctrl+C 用户中断**: 用户手动按 Ctrl+C 取消时不会触发任何 hook 事件,因此无法发送通知。
116
+ - **网络完全断开**: 如果网络完全断开导致 Claude Code 进程本身退出,hook 可能无法执行。API 层面的网络错误(如超时)会通过 `StopFailure` 的 `server_error` 类型捕获。
117
+
118
+ ## License
119
+
120
+ MIT
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "claude-hook-notify",
3
+ "version": "1.0.0",
4
+ "description": "🔔 Claude Code 任务完成桌面通知 — 一键安装,跨平台支持",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "claude-hook-notify": "src/cli.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node src/cli.js notify --event Stop --dry-run"
11
+ },
12
+ "keywords": [
13
+ "claude-code",
14
+ "hooks",
15
+ "notification",
16
+ "desktop-notification",
17
+ "osascript",
18
+ "notify-send",
19
+ "cli"
20
+ ],
21
+ "author": "",
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=16.0.0"
25
+ },
26
+ "files": [
27
+ "src/",
28
+ "README.md"
29
+ ]
30
+ }
package/src/cli.js ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { setup } = require("./setup");
4
+ const { uninstall } = require("./setup");
5
+ const { sendNotification } = require("./notify");
6
+
7
+ const HELP = `
8
+ claude-hook-notify — 🔔 Claude Code 任务完成桌面通知
9
+
10
+ 用法:
11
+ claude-hook-notify setup 一键安装 hooks 配置
12
+ claude-hook-notify uninstall 移除已安装的 hooks 配置
13
+ claude-hook-notify notify 发送通知(由 hook 自动调用)
14
+ claude-hook-notify help 显示帮助信息
15
+
16
+ setup 选项:
17
+ --global 安装到全局配置 ~/.claude/settings.json(默认)
18
+ --local 安装到当前项目 .claude/settings.json
19
+ --events <事件列表> 要监听的事件,逗号分隔
20
+ 默认: Stop,TaskCompleted,Notification,StopFailure
21
+
22
+ notify 选项:
23
+ --event <事件名> 事件类型 (Stop/TaskCompleted/Notification/...)
24
+ --title <标题> 自定义通知标题
25
+ --message <消息> 自定义通知消息
26
+ --sound <音效> macOS 音效名称 (默认: Glass)
27
+ --dry-run 仅打印通知内容,不实际发送
28
+
29
+ 示例:
30
+ npx claude-hook-notify setup
31
+ npx claude-hook-notify setup --events Stop,TaskCompleted
32
+ npx claude-hook-notify uninstall
33
+ `;
34
+
35
+ function parseArgs(args) {
36
+ const parsed = { _: [] };
37
+ for (let i = 0; i < args.length; i++) {
38
+ if (args[i].startsWith("--")) {
39
+ const key = args[i].slice(2);
40
+ if (
41
+ key === "dry-run" ||
42
+ key === "global" ||
43
+ key === "local" ||
44
+ key === "help"
45
+ ) {
46
+ parsed[key] = true;
47
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
48
+ parsed[key] = args[++i];
49
+ } else {
50
+ parsed[key] = true;
51
+ }
52
+ } else {
53
+ parsed._.push(args[i]);
54
+ }
55
+ }
56
+ return parsed;
57
+ }
58
+
59
+ async function main() {
60
+ const args = parseArgs(process.argv.slice(2));
61
+ const command = args._[0];
62
+
63
+ if (!command || command === "help" || args.help) {
64
+ console.log(HELP);
65
+ process.exit(0);
66
+ }
67
+
68
+ if (command === "setup") {
69
+ const scope = args.local ? "local" : "global";
70
+ const events = args.events
71
+ ? args.events.split(",").map((e) => e.trim())
72
+ : ["Stop", "TaskCompleted", "Notification", "StopFailure"];
73
+ await setup({ scope, events });
74
+ return;
75
+ }
76
+
77
+ if (command === "uninstall") {
78
+ const scope = args.local ? "local" : "global";
79
+ await uninstall({ scope });
80
+ return;
81
+ }
82
+
83
+ if (command === "notify") {
84
+ let input = {};
85
+ // 从 stdin 读取 hook JSON 数据(非交互模式)
86
+ if (!process.stdin.isTTY) {
87
+ try {
88
+ const chunks = [];
89
+ for await (const chunk of process.stdin) {
90
+ chunks.push(chunk);
91
+ }
92
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
93
+ if (raw) input = JSON.parse(raw);
94
+ } catch {
95
+ // stdin 为空或非 JSON,忽略
96
+ }
97
+ }
98
+
99
+ await sendNotification({
100
+ event: args.event || "Stop",
101
+ title: args.title,
102
+ message: args.message,
103
+ sound: args.sound,
104
+ dryRun: !!args["dry-run"],
105
+ hookInput: input,
106
+ });
107
+ return;
108
+ }
109
+
110
+ console.error(`未知命令: ${command}\n运行 claude-hook-notify help 查看帮助`);
111
+ process.exit(1);
112
+ }
113
+
114
+ main().catch((err) => {
115
+ console.error("错误:", err.message);
116
+ process.exit(1);
117
+ });
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ const { sendNotification, EVENT_CONFIG } = require("./notify");
2
+ const { setup, uninstall } = require("./setup");
3
+
4
+ module.exports = { sendNotification, EVENT_CONFIG, setup, uninstall };
package/src/notify.js ADDED
@@ -0,0 +1,274 @@
1
+ const { execSync } = require("child_process");
2
+ const os = require("os");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ const EVENT_CONFIG = {
7
+ Stop: {
8
+ title: "Claude Code 完成",
9
+ message: "Agent 已完成响应",
10
+ sound: "Glass",
11
+ },
12
+ TaskCompleted: {
13
+ title: "任务完成",
14
+ message: "任务已完成",
15
+ sound: "Hero",
16
+ },
17
+ Notification: {
18
+ title: "Claude Code 通知",
19
+ message: "需要你的注意",
20
+ sound: "Ping",
21
+ },
22
+ PostToolUseFailure: {
23
+ title: "工具执行失败",
24
+ message: "工具执行出错",
25
+ sound: "Basso",
26
+ },
27
+ SubagentStop: {
28
+ title: "子代理完成",
29
+ message: "子代理已完成任务",
30
+ sound: "Purr",
31
+ },
32
+ StopFailure: {
33
+ title: "Claude Code 错误",
34
+ message: "Claude Code 遇到错误中断",
35
+ sound: "Sosumi",
36
+ },
37
+ };
38
+
39
+ const ERROR_TYPE_LABELS = {
40
+ rate_limit: "请求频率限制",
41
+ authentication_failed: "认证失败",
42
+ billing_error: "账单/额度问题",
43
+ server_error: "服务器错误",
44
+ max_output_tokens: "输出超出 token 限制",
45
+ invalid_request: "无效请求",
46
+ unknown: "未知错误",
47
+ };
48
+
49
+ /**
50
+ * 从 hook 输入中提取有用信息
51
+ */
52
+ function extractContext(hookInput) {
53
+ const ctx = {};
54
+
55
+ // 项目目录
56
+ ctx.project = path.basename(process.cwd());
57
+
58
+ // 任务主题 (TaskCompleted 事件)
59
+ if (hookInput.task_subject) {
60
+ ctx.taskSubject = hookInput.task_subject;
61
+ }
62
+
63
+ // 工具名称 (PostToolUseFailure 事件)
64
+ if (hookInput.tool_name) {
65
+ ctx.toolName = hookInput.tool_name;
66
+ }
67
+
68
+ // 错误类型 (StopFailure 事件)
69
+ if (hookInput.error_type) {
70
+ ctx.errorType = hookInput.error_type;
71
+ }
72
+
73
+ // 错误消息 (StopFailure 事件)
74
+ if (hookInput.error_message) {
75
+ ctx.errorMessage = hookInput.error_message;
76
+ }
77
+
78
+ // 停止原因 (Stop 事件)
79
+ if (hookInput.stop_reason) {
80
+ ctx.stopReason = hookInput.stop_reason;
81
+ }
82
+
83
+ // 尝试从 transcript 提取最后的 assistant 消息
84
+ if (hookInput.transcript_path && fs.existsSync(hookInput.transcript_path)) {
85
+ try {
86
+ const lines = fs
87
+ .readFileSync(hookInput.transcript_path, "utf-8")
88
+ .split("\n")
89
+ .filter(Boolean)
90
+ .slice(-20);
91
+
92
+ for (let i = lines.length - 1; i >= 0; i--) {
93
+ try {
94
+ const entry = JSON.parse(lines[i]);
95
+ if (
96
+ entry?.message?.role === "assistant" &&
97
+ entry?.message?.content?.[0]?.text
98
+ ) {
99
+ ctx.lastMessage = entry.message.content[0].text
100
+ .replace(/\n/g, " ")
101
+ .slice(0, 100);
102
+ break;
103
+ }
104
+ } catch {
105
+ continue;
106
+ }
107
+ }
108
+ } catch {
109
+ // 读取失败,忽略
110
+ }
111
+ }
112
+
113
+ return ctx;
114
+ }
115
+
116
+ /**
117
+ * 构建通知标题和消息
118
+ */
119
+ function buildNotification({ event, title, message, hookInput }) {
120
+ const config = EVENT_CONFIG[event] || EVENT_CONFIG.Stop;
121
+ const ctx = extractContext(hookInput || {});
122
+
123
+ let finalTitle = title || `${config.title} (${ctx.project})`;
124
+ let finalMessage = message;
125
+
126
+ if (!finalMessage) {
127
+ if (event === "StopFailure" && ctx.errorType) {
128
+ const label = ERROR_TYPE_LABELS[ctx.errorType] || ctx.errorType;
129
+ finalTitle = `${config.title}: ${label} (${ctx.project})`;
130
+ finalMessage = ctx.errorMessage || config.message;
131
+ } else if (event === "Stop" && ctx.stopReason === "max_tokens") {
132
+ finalTitle = `Claude Code 响应截断 (${ctx.project})`;
133
+ finalMessage = "响应达到最大 token 限制被截断";
134
+ } else if (event === "TaskCompleted" && ctx.taskSubject) {
135
+ finalMessage = ctx.taskSubject;
136
+ } else if (event === "PostToolUseFailure" && ctx.toolName) {
137
+ finalMessage = `工具 ${ctx.toolName} 执行失败`;
138
+ } else if (ctx.lastMessage) {
139
+ finalMessage = ctx.lastMessage;
140
+ } else {
141
+ finalMessage = config.message;
142
+ }
143
+ }
144
+
145
+ return { title: finalTitle, message: finalMessage, sound: config.sound };
146
+ }
147
+
148
+ /**
149
+ * 检测命令是否存在
150
+ */
151
+ function commandExists(cmd) {
152
+ try {
153
+ execSync(
154
+ os.platform() === "win32" ? `where ${cmd}` : `command -v ${cmd}`,
155
+ { stdio: "ignore" }
156
+ );
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * 发送系统桌面通知
165
+ */
166
+ async function sendNotification(options = {}) {
167
+ const {
168
+ event = "Stop",
169
+ title: customTitle,
170
+ message: customMessage,
171
+ sound: customSound,
172
+ dryRun = false,
173
+ hookInput = {},
174
+ } = options;
175
+
176
+ const {
177
+ title,
178
+ message,
179
+ sound: defaultSound,
180
+ } = buildNotification({
181
+ event,
182
+ title: customTitle,
183
+ message: customMessage,
184
+ hookInput,
185
+ });
186
+ const sound = customSound || defaultSound;
187
+
188
+ const platform = os.platform();
189
+ let method = "unknown";
190
+ let command = "";
191
+ let args = [];
192
+
193
+ if (platform === "darwin") {
194
+ // macOS
195
+ if (commandExists("terminal-notifier")) {
196
+ method = "terminal-notifier";
197
+ command = "terminal-notifier";
198
+ args = [
199
+ "-title",
200
+ title,
201
+ "-message",
202
+ message,
203
+ "-sound",
204
+ sound,
205
+ "-group",
206
+ `claude-code-${extractContext(hookInput).project}`,
207
+ ];
208
+ } else {
209
+ method = "osascript";
210
+ command = "osascript";
211
+ const escaped = message.replace(/"/g, '\\"');
212
+ const escapedTitle = title.replace(/"/g, '\\"');
213
+ args = [
214
+ "-e",
215
+ `display notification "${escaped}" with title "${escapedTitle}" sound name "${sound}"`,
216
+ ];
217
+ }
218
+ } else if (platform === "linux") {
219
+ method = "notify-send";
220
+ command = "notify-send";
221
+ args = [title, message, "--urgency=normal", "--expire-time=5000"];
222
+ if (!commandExists("notify-send")) {
223
+ const result = {
224
+ sent: false,
225
+ method: "none",
226
+ error:
227
+ "notify-send 未安装。请运行: sudo apt-get install libnotify-bin",
228
+ };
229
+ if (dryRun) {
230
+ console.log(JSON.stringify(result, null, 2));
231
+ } else {
232
+ console.error(result.error);
233
+ }
234
+ return result;
235
+ }
236
+ } else if (platform === "win32") {
237
+ method = "powershell";
238
+ command = "powershell.exe";
239
+ const psScript = `
240
+ [void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');
241
+ $n = New-Object System.Windows.Forms.NotifyIcon;
242
+ $n.Icon = [System.Drawing.SystemIcons]::Information;
243
+ $n.BalloonTipTitle = '${title.replace(/'/g, "''")}';
244
+ $n.BalloonTipText = '${message.replace(/'/g, "''")}';
245
+ $n.Visible = $true;
246
+ $n.ShowBalloonTip(5000);
247
+ Start-Sleep -Seconds 6;
248
+ $n.Dispose();
249
+ `.replace(/\n/g, " ");
250
+ args = ["-NoProfile", "-Command", psScript];
251
+ }
252
+
253
+ const result = { sent: !dryRun, method, command, args };
254
+
255
+ if (dryRun) {
256
+ result.sent = false;
257
+ console.log(JSON.stringify(result, null, 2));
258
+ return result;
259
+ }
260
+
261
+ try {
262
+ if (command) {
263
+ const { execFileSync } = require("child_process");
264
+ execFileSync(command, args, { stdio: "ignore", timeout: 5000 });
265
+ }
266
+ } catch (err) {
267
+ result.sent = false;
268
+ result.error = err.message;
269
+ }
270
+
271
+ return result;
272
+ }
273
+
274
+ module.exports = { sendNotification, EVENT_CONFIG };
package/src/setup.js ADDED
@@ -0,0 +1,260 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+
5
+ const PKG_NAME = "claude-hook-notify";
6
+
7
+ // ANSI colors
8
+ const c = {
9
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
10
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
11
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
12
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
13
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
14
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
15
+ };
16
+
17
+ /**
18
+ * 生成 hooks 配置
19
+ */
20
+ function generateHooksConfig(events) {
21
+ const hooks = {};
22
+ for (const event of events) {
23
+ hooks[event] = [
24
+ {
25
+ matcher: "",
26
+ hooks: [
27
+ {
28
+ type: "command",
29
+ command: `npx --yes ${PKG_NAME}@latest notify --event ${event}`,
30
+ timeout: 10,
31
+ },
32
+ ],
33
+ },
34
+ ];
35
+ }
36
+ return hooks;
37
+ }
38
+
39
+ /**
40
+ * 获取配置文件路径
41
+ */
42
+ function getSettingsPath(scope) {
43
+ if (scope === "local") {
44
+ return path.join(process.cwd(), ".claude", "settings.json");
45
+ }
46
+ return path.join(os.homedir(), ".claude", "settings.json");
47
+ }
48
+
49
+ /**
50
+ * 安全读取 JSON 文件
51
+ */
52
+ function readJSON(filePath) {
53
+ try {
54
+ if (fs.existsSync(filePath)) {
55
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
56
+ }
57
+ } catch {
58
+ // 文件损坏或不可读
59
+ }
60
+ return {};
61
+ }
62
+
63
+ /**
64
+ * 安全写入 JSON 文件
65
+ */
66
+ function writeJSON(filePath, data) {
67
+ const dir = path.dirname(filePath);
68
+ if (!fs.existsSync(dir)) {
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ }
71
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
72
+ }
73
+
74
+ /**
75
+ * 检查平台依赖
76
+ */
77
+ function checkDependencies() {
78
+ const platform = os.platform();
79
+ const warnings = [];
80
+
81
+ if (platform === "linux") {
82
+ try {
83
+ require("child_process").execSync("command -v notify-send", {
84
+ stdio: "ignore",
85
+ });
86
+ } catch {
87
+ warnings.push(
88
+ `${c.yellow("⚠")} 未检测到 notify-send,请安装:${c.cyan("sudo apt-get install libnotify-bin")}`
89
+ );
90
+ }
91
+ }
92
+
93
+ if (platform === "darwin") {
94
+ try {
95
+ require("child_process").execSync("command -v terminal-notifier", {
96
+ stdio: "ignore",
97
+ });
98
+ console.log(
99
+ `${c.green("✓")} 检测到 terminal-notifier(增强通知体验)`
100
+ );
101
+ } catch {
102
+ console.log(
103
+ `${c.dim("ℹ")} 可选:${c.cyan("brew install terminal-notifier")} 获得更好的通知体验`
104
+ );
105
+ }
106
+ }
107
+
108
+ return warnings;
109
+ }
110
+
111
+ /**
112
+ * 安装 hooks 配置
113
+ */
114
+ async function setup({ scope = "global", events = ["Stop", "TaskCompleted", "Notification", "StopFailure"] }) {
115
+ const settingsPath = getSettingsPath(scope);
116
+ const scopeLabel = scope === "global" ? "全局" : "项目";
117
+
118
+ console.log();
119
+ console.log(
120
+ ` ${c.bold("🔔 Claude Code Notify — 安装向导")}`
121
+ );
122
+ console.log();
123
+
124
+ // 检查依赖
125
+ const warnings = checkDependencies();
126
+ warnings.forEach((w) => console.log(` ${w}`));
127
+
128
+ // 读取现有配置
129
+ const settings = readJSON(settingsPath);
130
+ const existingHooks = settings.hooks || {};
131
+
132
+ // 检查是否已安装
133
+ const alreadyInstalled = events.some(
134
+ (event) =>
135
+ existingHooks[event]?.some((h) =>
136
+ h.hooks?.some((hh) => hh.command?.includes(PKG_NAME))
137
+ )
138
+ );
139
+
140
+ if (alreadyInstalled) {
141
+ console.log(
142
+ ` ${c.yellow("⚠")} 检测到已有 ${PKG_NAME} 的 hook 配置`
143
+ );
144
+ console.log(` ${c.dim(" 将会覆盖现有的通知 hook 配置")}`);
145
+ console.log();
146
+ }
147
+
148
+ // 生成新的 hooks 配置
149
+ const newHooks = generateHooksConfig(events);
150
+
151
+ // 合并配置(只覆盖 claude-hook-notify 相关的 hook,保留其他 hook)
152
+ for (const [event, hookConfigs] of Object.entries(newHooks)) {
153
+ if (existingHooks[event]) {
154
+ // 移除旧的 claude-hook-notify hook,保留其他
155
+ existingHooks[event] = existingHooks[event].filter(
156
+ (h) => !h.hooks?.some((hh) => hh.command?.includes(PKG_NAME))
157
+ );
158
+ // 追加新的
159
+ existingHooks[event].push(...hookConfigs);
160
+ } else {
161
+ existingHooks[event] = hookConfigs;
162
+ }
163
+ }
164
+
165
+ settings.hooks = existingHooks;
166
+
167
+ // 写入配置
168
+ writeJSON(settingsPath, settings);
169
+
170
+ // 输出结果
171
+ console.log(
172
+ ` ${c.green("✓")} 已写入${scopeLabel}配置: ${c.cyan(settingsPath)}`
173
+ );
174
+ console.log();
175
+ console.log(` ${c.bold("已注册的事件:")}`);
176
+ for (const event of events) {
177
+ const labels = {
178
+ Stop: "Agent 完成响应时通知",
179
+ TaskCompleted: "子任务完成时通知",
180
+ Notification: "需要用户注意时通知",
181
+ PostToolUseFailure: "工具执行失败时通知",
182
+ SubagentStop: "子代理完成时通知",
183
+ StopFailure: "API 错误导致中断时通知",
184
+ };
185
+ console.log(
186
+ ` ${c.green("•")} ${c.bold(event)} — ${labels[event] || "自定义事件"}`
187
+ );
188
+ }
189
+ console.log();
190
+ console.log(
191
+ ` ${c.dim("重启 Claude Code 后生效。")}`
192
+ );
193
+ console.log(
194
+ ` ${c.dim(`卸载: npx ${PKG_NAME} uninstall${scope === "local" ? " --local" : ""}`)}`
195
+ );
196
+ console.log();
197
+ }
198
+
199
+ /**
200
+ * 卸载 hooks 配置
201
+ */
202
+ async function uninstall({ scope = "global" }) {
203
+ const settingsPath = getSettingsPath(scope);
204
+ const scopeLabel = scope === "global" ? "全局" : "项目";
205
+
206
+ console.log();
207
+ console.log(` ${c.bold("🔔 Claude Code Notify — 卸载")}`);
208
+ console.log();
209
+
210
+ if (!fs.existsSync(settingsPath)) {
211
+ console.log(` ${c.yellow("⚠")} 未找到配置文件: ${settingsPath}`);
212
+ console.log();
213
+ return;
214
+ }
215
+
216
+ const settings = readJSON(settingsPath);
217
+ if (!settings.hooks) {
218
+ console.log(` ${c.yellow("⚠")} 配置中没有 hooks`);
219
+ console.log();
220
+ return;
221
+ }
222
+
223
+ let removed = 0;
224
+
225
+ for (const [event, hookConfigs] of Object.entries(settings.hooks)) {
226
+ const before = hookConfigs.length;
227
+ settings.hooks[event] = hookConfigs.filter(
228
+ (h) => !h.hooks?.some((hh) => hh.command?.includes(PKG_NAME))
229
+ );
230
+ removed += before - settings.hooks[event].length;
231
+
232
+ // 清理空数组
233
+ if (settings.hooks[event].length === 0) {
234
+ delete settings.hooks[event];
235
+ }
236
+ }
237
+
238
+ // 清理空 hooks 对象
239
+ if (Object.keys(settings.hooks).length === 0) {
240
+ delete settings.hooks;
241
+ }
242
+
243
+ writeJSON(settingsPath, settings);
244
+
245
+ if (removed > 0) {
246
+ console.log(
247
+ ` ${c.green("✓")} 已从${scopeLabel}配置中移除 ${removed} 个通知 hook`
248
+ );
249
+ } else {
250
+ console.log(
251
+ ` ${c.yellow("ℹ")} 未找到 ${PKG_NAME} 相关的 hook 配置`
252
+ );
253
+ }
254
+ console.log(
255
+ ` ${c.dim("配置文件: " + settingsPath)}`
256
+ );
257
+ console.log();
258
+ }
259
+
260
+ module.exports = { setup, uninstall };