claude-hook-notify 1.0.0 → 1.2.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 CHANGED
@@ -73,14 +73,13 @@ npx claude-hook-notify notify --title "构建完成" --message "所有测试通
73
73
  | `Stop` | Claude Code 完成一次响应 | Glass |
74
74
  | `TaskCompleted` | 子任务被标记为完成 | Hero |
75
75
  | `Notification` | Claude Code 需要你注意(等输入) | Ping |
76
- | `StopFailure` | API 错误导致中断(限流/认证/服务器错误等) | Sosumi |
77
76
 
78
77
  > **注意**: `Stop` 事件在响应因 token 限制被截断时会显示特殊提示。
79
78
 
80
- 也可以添加额外事件:
79
+ 也可以添加额外事件(注意:`StopFailure` 不是 Claude Code 官方支持的 hook 事件,使用会导致配置报错):
81
80
 
82
81
  ```bash
83
- npx claude-hook-notify setup --events Stop,TaskCompleted,Notification,StopFailure,PostToolUseFailure,SubagentStop
82
+ npx claude-hook-notify setup --events Stop,TaskCompleted,Notification,PostToolUseFailure,SubagentStop
84
83
  ```
85
84
 
86
85
  ## 平台支持
@@ -113,7 +112,8 @@ await sendNotification({
113
112
  ## 已知限制
114
113
 
115
114
  - **Ctrl+C 用户中断**: 用户手动按 Ctrl+C 取消时不会触发任何 hook 事件,因此无法发送通知。
116
- - **网络完全断开**: 如果网络完全断开导致 Claude Code 进程本身退出,hook 可能无法执行。API 层面的网络错误(如超时)会通过 `StopFailure` 的 `server_error` 类型捕获。
115
+ - **网络完全断开**: 如果网络完全断开导致 Claude Code 进程本身退出,hook 可能无法执行。
116
+ - **StopFailure 事件**: `StopFailure` 不是 Claude Code 官方支持的 hook 事件名,默认不再注册。如通过 `--events` 手动指定,会导致 settings.json 校验报错,整个配置文件被跳过。
117
117
 
118
118
  ## License
119
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-hook-notify",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "🔔 Claude Code 任务完成桌面通知 — 一键安装,跨平台支持",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -17,7 +17,7 @@ const HELP = `
17
17
  --global 安装到全局配置 ~/.claude/settings.json(默认)
18
18
  --local 安装到当前项目 .claude/settings.json
19
19
  --events <事件列表> 要监听的事件,逗号分隔
20
- 默认: Stop,TaskCompleted,Notification,StopFailure
20
+ 默认: Stop,TaskCompleted,Notification
21
21
 
22
22
  notify 选项:
23
23
  --event <事件名> 事件类型 (Stop/TaskCompleted/Notification/...)
@@ -69,7 +69,7 @@ async function main() {
69
69
  const scope = args.local ? "local" : "global";
70
70
  const events = args.events
71
71
  ? args.events.split(",").map((e) => e.trim())
72
- : ["Stop", "TaskCompleted", "Notification", "StopFailure"];
72
+ : ["Stop", "TaskCompleted", "Notification"];
73
73
  await setup({ scope, events });
74
74
  return;
75
75
  }
package/src/notify.js CHANGED
@@ -234,20 +234,38 @@ async function sendNotification(options = {}) {
234
234
  return result;
235
235
  }
236
236
  } else if (platform === "win32") {
237
- method = "powershell";
237
+ method = "powershell-toast";
238
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];
239
+ const t = title.replace(/'/g, "''");
240
+ const m = message.replace(/'/g, "''");
241
+ const appId =
242
+ "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
243
+ const psScript = [
244
+ "$t='" + t + "'",
245
+ "$m='" + m + "'",
246
+ "try{",
247
+ " [void][Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]",
248
+ " [void][Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom,ContentType=WindowsRuntime]",
249
+ " $x=New-Object Windows.Data.Xml.Dom.XmlDocument",
250
+ " $te=[System.Security.SecurityElement]::Escape($t)",
251
+ " $me=[System.Security.SecurityElement]::Escape($m)",
252
+ ' $x.LoadXml("<toast><visual><binding template=\'ToastGeneric\'><text>$te</text><text>$me</text></binding></visual></toast>")',
253
+ " $toast=[Windows.UI.Notifications.ToastNotification]::new($x)",
254
+ " [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('" + appId + "').Show($toast)",
255
+ "}catch{",
256
+ " [void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')",
257
+ " $n=New-Object System.Windows.Forms.NotifyIcon",
258
+ " $n.Icon=[System.Drawing.SystemIcons]::Information",
259
+ " $n.BalloonTipTitle=$t",
260
+ " $n.BalloonTipText=$m",
261
+ " $n.Visible=$true",
262
+ " $n.ShowBalloonTip(5000)",
263
+ " Start-Sleep -Seconds 6",
264
+ " $n.Dispose()",
265
+ "}",
266
+ ].join("\n");
267
+ const encoded = Buffer.from(psScript, "utf16le").toString("base64");
268
+ args = ["-NoProfile", "-EncodedCommand", encoded];
251
269
  }
252
270
 
253
271
  const result = { sent: !dryRun, method, command, args };
@@ -260,8 +278,19 @@ async function sendNotification(options = {}) {
260
278
 
261
279
  try {
262
280
  if (command) {
263
- const { execFileSync } = require("child_process");
264
- execFileSync(command, args, { stdio: "ignore", timeout: 5000 });
281
+ if (platform === "win32") {
282
+ // Windows: spawn detached so hook exits immediately, notification lives independently
283
+ const { spawn } = require("child_process");
284
+ const child = spawn(command, args, {
285
+ detached: true,
286
+ stdio: "ignore",
287
+ windowsHide: true,
288
+ });
289
+ child.unref();
290
+ } else {
291
+ const { execFileSync } = require("child_process");
292
+ execFileSync(command, args, { stdio: "ignore", timeout: 5000 });
293
+ }
265
294
  }
266
295
  } catch (err) {
267
296
  result.sent = false;
package/src/setup.js CHANGED
@@ -111,7 +111,7 @@ function checkDependencies() {
111
111
  /**
112
112
  * 安装 hooks 配置
113
113
  */
114
- async function setup({ scope = "global", events = ["Stop", "TaskCompleted", "Notification", "StopFailure"] }) {
114
+ async function setup({ scope = "global", events = ["Stop", "TaskCompleted", "Notification"] }) {
115
115
  const settingsPath = getSettingsPath(scope);
116
116
  const scopeLabel = scope === "global" ? "全局" : "项目";
117
117
 
@@ -196,6 +196,44 @@ async function setup({ scope = "global", events = ["Stop", "TaskCompleted", "Not
196
196
  console.log();
197
197
  }
198
198
 
199
+ /**
200
+ * 清理 npx 缓存中的 claude-hook-notify
201
+ */
202
+ function cleanNpxCache() {
203
+ let cacheDir;
204
+ try {
205
+ cacheDir = require("child_process")
206
+ .execSync("npm config get cache", { encoding: "utf-8" })
207
+ .trim();
208
+ } catch {
209
+ return 0;
210
+ }
211
+
212
+ const npxDir = path.join(cacheDir, "_npx");
213
+ if (!fs.existsSync(npxDir)) return 0;
214
+
215
+ let cleaned = 0;
216
+ try {
217
+ const entries = fs.readdirSync(npxDir);
218
+ for (const entry of entries) {
219
+ const pkgPath = path.join(npxDir, entry, "package.json");
220
+ try {
221
+ if (!fs.existsSync(pkgPath)) continue;
222
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
223
+ if (pkg.dependencies && pkg.dependencies[PKG_NAME]) {
224
+ fs.rmSync(path.join(npxDir, entry), { recursive: true, force: true });
225
+ cleaned++;
226
+ }
227
+ } catch {
228
+ // 跳过无法读取的目录
229
+ }
230
+ }
231
+ } catch {
232
+ // 无法读取 npx 缓存目录
233
+ }
234
+ return cleaned;
235
+ }
236
+
199
237
  /**
200
238
  * 卸载 hooks 配置
201
239
  */
@@ -207,41 +245,34 @@ async function uninstall({ scope = "global" }) {
207
245
  console.log(` ${c.bold("🔔 Claude Code Notify — 卸载")}`);
208
246
  console.log();
209
247
 
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
-
248
+ // 清理 hooks 配置
223
249
  let removed = 0;
224
250
 
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];
251
+ if (fs.existsSync(settingsPath)) {
252
+ const settings = readJSON(settingsPath);
253
+ if (settings.hooks) {
254
+ for (const [event, hookConfigs] of Object.entries(settings.hooks)) {
255
+ const before = hookConfigs.length;
256
+ settings.hooks[event] = hookConfigs.filter(
257
+ (h) => !h.hooks?.some((hh) => hh.command?.includes(PKG_NAME))
258
+ );
259
+ removed += before - settings.hooks[event].length;
260
+
261
+ // 清理空数组
262
+ if (settings.hooks[event].length === 0) {
263
+ delete settings.hooks[event];
264
+ }
265
+ }
266
+
267
+ // 清理空 hooks 对象
268
+ if (Object.keys(settings.hooks).length === 0) {
269
+ delete settings.hooks;
270
+ }
271
+
272
+ writeJSON(settingsPath, settings);
235
273
  }
236
274
  }
237
275
 
238
- // 清理空 hooks 对象
239
- if (Object.keys(settings.hooks).length === 0) {
240
- delete settings.hooks;
241
- }
242
-
243
- writeJSON(settingsPath, settings);
244
-
245
276
  if (removed > 0) {
246
277
  console.log(
247
278
  ` ${c.green("✓")} 已从${scopeLabel}配置中移除 ${removed} 个通知 hook`
@@ -254,6 +285,15 @@ async function uninstall({ scope = "global" }) {
254
285
  console.log(
255
286
  ` ${c.dim("配置文件: " + settingsPath)}`
256
287
  );
288
+
289
+ // 清理 npx 缓存
290
+ const cleaned = cleanNpxCache();
291
+ if (cleaned > 0) {
292
+ console.log(
293
+ ` ${c.green("✓")} 已清理 ${cleaned} 个 npx 缓存目录`
294
+ );
295
+ }
296
+
257
297
  console.log();
258
298
  }
259
299