claude-notification-plugin 1.0.110 → 1.0.115

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-notification-plugin",
3
- "version": "1.0.110",
3
+ "version": "1.0.115",
4
4
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
5
5
  "author": {
6
6
  "name": "Viacheslav Makarov",
package/bin/install.js CHANGED
@@ -107,10 +107,14 @@ function stopListenerIfRunning () {
107
107
  let stopped = false;
108
108
  const pidFromFile = (() => {
109
109
  try {
110
- if (!fs.existsSync(pidFile)) return null;
110
+ if (!fs.existsSync(pidFile)) {
111
+ return null;
112
+ }
111
113
  const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
112
114
  return Number.isInteger(pid) && pid > 0 ? pid : null;
113
- } catch { return null; }
115
+ } catch {
116
+ return null;
117
+ }
114
118
  })();
115
119
 
116
120
  if (pidFromFile && killProcessTree(pidFromFile)) {
@@ -398,24 +402,15 @@ async function fetchChatId (token) {
398
402
  return null;
399
403
  }
400
404
 
401
- function addHook (settings, event) {
405
+ function removeHook (settings, event) {
402
406
  if (!settings.hooks[event]) {
403
- settings.hooks[event] = [];
407
+ return;
404
408
  }
405
-
406
- const exists = settings.hooks[event].some((matcher) =>
407
- matcher.hooks?.some((h) => h.command?.includes(HOOK_COMMAND)),
409
+ settings.hooks[event] = settings.hooks[event].filter((matcher) =>
410
+ !matcher.hooks?.some((h) => h.command?.includes(HOOK_COMMAND)),
408
411
  );
409
-
410
- if (!exists) {
411
- settings.hooks[event].push({
412
- hooks: [
413
- {
414
- type: 'command',
415
- command: HOOK_COMMAND,
416
- },
417
- ],
418
- });
412
+ if (settings.hooks[event].length === 0) {
413
+ delete settings.hooks[event];
419
414
  }
420
415
  }
421
416
 
@@ -595,16 +590,17 @@ Send any message to your bot in Telegram, then press Enter.\x1b[0m`);
595
590
  }
596
591
  }
597
592
 
598
- settings.hooks = settings.hooks || {};
599
-
600
- addHook(settings, 'UserPromptSubmit');
601
- addHook(settings, 'Stop');
602
- addHook(settings, 'Notification');
603
-
604
593
  // Register plugin as enabled
605
594
  settings.enabledPlugins = settings.enabledPlugins || {};
606
595
  settings.enabledPlugins[PLUGIN_KEY] = true;
607
596
 
597
+ // When the plugin is enabled, Claude Code loads hooks from hooks/hooks.json automatically.
598
+ // Remove any duplicate hooks from settings.json to avoid double notifications.
599
+ settings.hooks = settings.hooks || {};
600
+ removeHook(settings, 'UserPromptSubmit');
601
+ removeHook(settings, 'Stop');
602
+ removeHook(settings, 'Notification');
603
+
608
604
  // Register marketplace
609
605
  settings.extraKnownMarketplaces = settings.extraKnownMarketplaces || {};
610
606
  settings.extraKnownMarketplaces[MARKETPLACE_KEY] = {
@@ -635,7 +631,7 @@ Send any message to your bot in Telegram, then press Enter.\x1b[0m`);
635
631
  console.log(`
636
632
  Installed!
637
633
  ${listenerLine}
638
- Hooks registered:
634
+ Plugin hooks (via hooks/hooks.json):
639
635
  - UserPromptSubmit (start timer)
640
636
  - Stop (task finished)
641
637
  - Notification (waiting for input)
@@ -652,4 +648,12 @@ To disable per project, add to .claude/settings.local.json: { "env": { "CLAUDE_N
652
648
  closeLog();
653
649
  }
654
650
 
651
+ // Skip postinstall for local (non-global) npm installs
652
+ const isGlobal = process.env.npm_config_global === 'true'
653
+ || process.env.npm_lifecycle_event !== 'postinstall';
654
+ if (process.env.npm_lifecycle_event === 'postinstall' && !isGlobal) {
655
+ console.log('claude-notification-plugin: skipping postinstall (local install detected)');
656
+ process.exit(0);
657
+ }
658
+
655
659
  main().then(() => 0);
@@ -278,6 +278,91 @@ function ask (rl, question) {
278
278
  });
279
279
  }
280
280
 
281
+ function askYesNo (rl, question) {
282
+ return new Promise((resolve) => {
283
+ rl.question(question, (answer) => {
284
+ const a = answer.trim().toLowerCase();
285
+ resolve(a === 'y' || a === 'yes');
286
+ });
287
+ });
288
+ }
289
+
290
+ function isValidPath (p) {
291
+ if (!p || p.includes('\0')) {
292
+ return false;
293
+ }
294
+ try {
295
+ path.resolve(p);
296
+ return true;
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+
302
+ function ensureDir (dirPath) {
303
+ try {
304
+ fs.mkdirSync(dirPath, { recursive: true });
305
+ return fs.existsSync(dirPath);
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+
311
+ function isValidAlias (alias) {
312
+ return /^[a-zA-Z0-9_-]+$/.test(alias);
313
+ }
314
+
315
+ function parsePositiveInt (str, fallback) {
316
+ if (!str) {
317
+ return fallback;
318
+ }
319
+ const n = parseInt(str, 10);
320
+ if (isNaN(n) || n <= 0) {
321
+ console.log(` \u26a0 Invalid value "${str}". Using default: ${fallback}`);
322
+ return fallback;
323
+ }
324
+ return n;
325
+ }
326
+
327
+ function validateDir (inputPath, defaultPath) {
328
+ const chosen = inputPath || defaultPath;
329
+ if (!isValidPath(chosen)) {
330
+ console.log(` \u26a0 Invalid path "${chosen}". Using default: ${defaultPath}`);
331
+ if (!ensureDir(defaultPath)) {
332
+ console.log(` \u26a0 Cannot create "${defaultPath}". Please check permissions.`);
333
+ }
334
+ return defaultPath;
335
+ }
336
+ if (!ensureDir(chosen)) {
337
+ console.log(` \u26a0 Cannot create "${chosen}". Using default: ${defaultPath}`);
338
+ ensureDir(defaultPath);
339
+ return defaultPath;
340
+ }
341
+ return chosen;
342
+ }
343
+
344
+ async function validateProjectPath (rl, inputPath) {
345
+ if (!inputPath) {
346
+ return null;
347
+ }
348
+ if (!isValidPath(inputPath)) {
349
+ console.log(' \u26a0 Invalid path. Project will not be set.');
350
+ return null;
351
+ }
352
+ const resolved = path.resolve(inputPath);
353
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
354
+ return resolved;
355
+ }
356
+ const create = await askYesNo(rl, ` Directory "${resolved}" does not exist. Create it? (y/n): `);
357
+ if (create) {
358
+ if (ensureDir(resolved)) {
359
+ return resolved;
360
+ }
361
+ console.log(` \u26a0 Cannot create "${resolved}".`);
362
+ }
363
+ return null;
364
+ }
365
+
281
366
  async function setupListener () {
282
367
  let config = {};
283
368
  if (fs.existsSync(CONFIG_FILE)) {
@@ -313,33 +398,114 @@ Listener Setup
313
398
  Press Enter to keep current value shown in [brackets].
314
399
  `);
315
400
 
316
- const worktreeBaseDir = await ask(rl, `Worktree base dir [${defaults.worktreeBaseDir}]: `) || defaults.worktreeBaseDir;
317
- const taskTimeoutStr = await ask(rl, `Task timeout, minutes [${defaults.taskTimeoutMinutes}]: `) || String(defaults.taskTimeoutMinutes);
318
- const maxQueueStr = await ask(rl, `Max queue per work dir [${defaults.maxQueuePerWorkDir}]: `) || String(defaults.maxQueuePerWorkDir);
319
- const maxTotalStr = await ask(rl, `Max total tasks [${defaults.maxTotalTasks}]: `) || String(defaults.maxTotalTasks);
320
- const logDir = await ask(rl, `Log dir [${defaults.logDir}]: `) || defaults.logDir;
321
- const taskLogDir = await ask(rl, `Task log dir [${defaults.taskLogDir}]: `) || defaults.taskLogDir;
322
- const projectPath = await ask(rl, `Default project path [${defaults.projectPath || '(none)'}]: `) || defaults.projectPath;
401
+ // --- Directories ---
402
+ const worktreeInput = await ask(rl, `Worktree base dir [${defaults.worktreeBaseDir}]: `);
403
+ L.worktreeBaseDir = validateDir(worktreeInput, defaults.worktreeBaseDir);
323
404
 
324
- rl.close();
405
+ const logDirInput = await ask(rl, `Log dir [${defaults.logDir}]: `);
406
+ L.logDir = validateDir(logDirInput, defaults.logDir);
407
+
408
+ const taskLogDirInput = await ask(rl, `Task log dir [${defaults.taskLogDir}]: `);
409
+ L.taskLogDir = validateDir(taskLogDirInput, defaults.taskLogDir);
410
+
411
+ // --- Numeric params ---
412
+ const taskTimeoutStr = await ask(rl, `Task timeout, minutes [${defaults.taskTimeoutMinutes}]: `);
413
+ L.taskTimeoutMinutes = parsePositiveInt(taskTimeoutStr, defaults.taskTimeoutMinutes);
414
+
415
+ const maxQueueStr = await ask(rl, `Max queue per work dir [${defaults.maxQueuePerWorkDir}]: `);
416
+ L.maxQueuePerWorkDir = parsePositiveInt(maxQueueStr, defaults.maxQueuePerWorkDir);
417
+
418
+ const maxTotalStr = await ask(rl, `Max total tasks [${defaults.maxTotalTasks}]: `);
419
+ L.maxTotalTasks = parsePositiveInt(maxTotalStr, defaults.maxTotalTasks);
325
420
 
326
- L.worktreeBaseDir = worktreeBaseDir;
327
- L.taskTimeoutMinutes = parseInt(taskTimeoutStr, 10) || defaults.taskTimeoutMinutes;
328
- L.maxQueuePerWorkDir = parseInt(maxQueueStr, 10) || defaults.maxQueuePerWorkDir;
329
- L.maxTotalTasks = parseInt(maxTotalStr, 10) || defaults.maxTotalTasks;
330
- L.logDir = logDir;
331
- L.taskLogDir = taskLogDir;
421
+ // --- Default project ---
422
+ console.log('');
423
+ const projectInput = await ask(rl, `Default project path [${defaults.projectPath || '(none)'}]: `);
424
+ const rawProjectPath = projectInput || defaults.projectPath;
332
425
 
333
- if (projectPath) {
334
- L.projects = L.projects || {};
335
- L.projects.default = L.projects.default || {};
336
- L.projects.default.path = projectPath;
426
+ L.projects = L.projects || {};
427
+ let hasValidProject = false;
428
+
429
+ if (rawProjectPath) {
430
+ const validatedPath = await validateProjectPath(rl, rawProjectPath);
431
+ if (validatedPath) {
432
+ L.projects.default = L.projects.default || {};
433
+ L.projects.default.path = validatedPath;
434
+ hasValidProject = true;
435
+ } else {
436
+ delete L.projects.default;
437
+ console.log(' \u26a0 Default project will not be set. Listener will not start without at least one project.');
438
+ }
439
+ } else {
440
+ delete L.projects.default;
441
+ console.log(' \u26a0 No default project configured. Listener will not start without at least one project.');
442
+ }
443
+
444
+ // --- Additional projects loop ---
445
+ // Count existing non-default projects
446
+ const existingAliases = Object.keys(L.projects).filter(a => a !== 'default');
447
+ if (existingAliases.length > 0) {
448
+ console.log(`\nExisting projects: ${existingAliases.join(', ')}`);
337
449
  }
338
450
 
451
+ while (true) {
452
+ console.log('');
453
+ const addMore = await askYesNo(rl, 'Add another project? (y/n): ');
454
+ if (!addMore) {
455
+ break;
456
+ }
457
+
458
+ // Ask alias with validation loop
459
+ let alias = '';
460
+ while (true) {
461
+ alias = await ask(rl, 'Project alias: ');
462
+ if (!alias) {
463
+ console.log(' \u26a0 Alias cannot be empty.');
464
+ continue;
465
+ }
466
+ if (alias === 'default') {
467
+ console.log(' \u26a0 "default" is reserved. Choose a different name.');
468
+ continue;
469
+ }
470
+ if (!isValidAlias(alias)) {
471
+ console.log(' \u26a0 Invalid alias. Allowed characters: a-z, A-Z, 0-9, -, _');
472
+ continue;
473
+ }
474
+ if (L.projects[alias]) {
475
+ console.log(` \u26a0 Alias "${alias}" already exists. Choose a different name.`);
476
+ continue;
477
+ }
478
+ break;
479
+ }
480
+
481
+ // Ask path with validation
482
+ const projPathInput = await ask(rl, `Project path for "${alias}": `);
483
+ if (!projPathInput) {
484
+ console.log(` \u26a0 Project "${alias}" was not added (no path provided).`);
485
+ continue;
486
+ }
487
+
488
+ const validatedProjPath = await validateProjectPath(rl, projPathInput);
489
+ if (validatedProjPath) {
490
+ L.projects[alias] = { path: validatedProjPath };
491
+ hasValidProject = true;
492
+ console.log(` \u2713 Project "${alias}" added: ${validatedProjPath}`);
493
+ } else {
494
+ console.log(` \u26a0 Project "${alias}" was not added (invalid path).`);
495
+ }
496
+ }
497
+
498
+ rl.close();
499
+
339
500
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
340
501
 
341
- console.log(`
342
- Listener config saved to ${CONFIG_FILE}
343
- Run "claude-notify listener start" to apply.
344
- `);
502
+ const projectCount = Object.keys(L.projects).length;
503
+ console.log(`\nListener config saved to ${CONFIG_FILE}`);
504
+ if (hasValidProject || projectCount > 0) {
505
+ console.log('Run "claude-notify listener start" to apply.');
506
+ } else {
507
+ console.log('\u26a0 No projects configured. Listener will not start until at least one project is added.');
508
+ console.log('Run "claude-notify listener setup" again to add projects.');
509
+ }
510
+ console.log('');
345
511
  }
package/commit-sha CHANGED
@@ -1 +1 @@
1
- 18261926cec70dd6480c219b4c5323291a0df7ad
1
+ 02ac0d7e44dac4ff0bb58a912fa8c92825f90cf4
@@ -51,10 +51,40 @@ if (!token || !chatId) {
51
51
 
52
52
  if (!config.listener?.projects || Object.keys(config.listener.projects).length === 0) {
53
53
  logger.error('No projects defined in config.listener.projects');
54
- console.error('No projects defined in config.listener.projects');
54
+ console.error('No projects defined in config.listener.projects. Run "claude-notify listener setup" to configure.');
55
55
  process.exit(1);
56
56
  }
57
57
 
58
+ // Validate project paths — skip projects with missing/invalid directories
59
+ const validatedProjects = {};
60
+ for (const [alias, proj] of Object.entries(config.listener.projects)) {
61
+ const projPath = typeof proj === 'string' ? proj : proj?.path;
62
+ if (!projPath) {
63
+ logger.warn(`Project "${alias}": no path configured, skipping`);
64
+ console.error(`\u26a0 Project "${alias}": no path configured, skipping`);
65
+ continue;
66
+ }
67
+ try {
68
+ const stat = fs.statSync(projPath);
69
+ if (!stat.isDirectory()) {
70
+ logger.warn(`Project "${alias}": path "${projPath}" is not a directory, skipping`);
71
+ console.error(`\u26a0 Project "${alias}": path "${projPath}" is not a directory, skipping`);
72
+ continue;
73
+ }
74
+ validatedProjects[alias] = proj;
75
+ } catch {
76
+ logger.warn(`Project "${alias}": path "${projPath}" does not exist, skipping`);
77
+ console.error(`\u26a0 Project "${alias}": path "${projPath}" does not exist, skipping`);
78
+ }
79
+ }
80
+
81
+ if (Object.keys(validatedProjects).length === 0) {
82
+ logger.error('No projects with valid paths found in config.listener.projects');
83
+ console.error('No projects with valid paths found. Run "claude-notify listener setup" to configure.');
84
+ process.exit(1);
85
+ }
86
+
87
+ config.listener.projects = validatedProjects;
58
88
  const listenerConfig = config.listener;
59
89
  const taskTimeoutMinutes = listenerConfig.taskTimeoutMinutes || 30;
60
90
  const taskTimeout = taskTimeoutMinutes * 60_000;
@@ -655,4 +685,7 @@ async function mainLoop () {
655
685
  }
656
686
  }
657
687
 
658
- mainLoop();
688
+ (async () => {
689
+ await poller.flush();
690
+ await mainLoop();
691
+ })();
@@ -12,6 +12,19 @@ export class TelegramPoller {
12
12
  this.offset = 0;
13
13
  }
14
14
 
15
+ async flush () {
16
+ try {
17
+ const url = `${this.baseUrl}/getUpdates?offset=-1&timeout=0&allowed_updates=["message"]`;
18
+ const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
19
+ const data = await res.json();
20
+ if (data.ok && data.result?.length) {
21
+ this.offset = data.result[data.result.length - 1].update_id + 1;
22
+ }
23
+ } catch {
24
+ // ignore — first getUpdates will still work with offset=0
25
+ }
26
+ }
27
+
15
28
  async getUpdates () {
16
29
  try {
17
30
  const url = `${this.baseUrl}/getUpdates?offset=${this.offset}&timeout=${POLL_TIMEOUT}&allowed_updates=["message"]`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-notification-plugin",
3
3
  "productName": "claude-notification-plugin",
4
- "version": "1.0.110",
4
+ "version": "1.0.115",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {