pingthings 0.4.0 → 0.5.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.
Files changed (52) hide show
  1. package/README.md +12 -0
  2. package/bin/pingthings.js +6 -0
  3. package/package.json +1 -1
  4. package/packs/fighting-announcer/manifest.json +62 -0
  5. package/packs/fighting-announcer/sounds/begin.wav +0 -0
  6. package/packs/fighting-announcer/sounds/complete.wav +0 -0
  7. package/packs/fighting-announcer/sounds/congratulations.wav +0 -0
  8. package/packs/fighting-announcer/sounds/defeat.wav +0 -0
  9. package/packs/fighting-announcer/sounds/failure.wav +0 -0
  10. package/packs/fighting-announcer/sounds/fight.wav +0 -0
  11. package/packs/fighting-announcer/sounds/final-round.wav +0 -0
  12. package/packs/fighting-announcer/sounds/finish.wav +0 -0
  13. package/packs/fighting-announcer/sounds/game-over.wav +0 -0
  14. package/packs/fighting-announcer/sounds/get-set.wav +0 -0
  15. package/packs/fighting-announcer/sounds/go.wav +0 -0
  16. package/packs/fighting-announcer/sounds/great.wav +0 -0
  17. package/packs/fighting-announcer/sounds/high-score.wav +0 -0
  18. package/packs/fighting-announcer/sounds/new-record.wav +0 -0
  19. package/packs/fighting-announcer/sounds/no-contest.wav +0 -0
  20. package/packs/fighting-announcer/sounds/ready.wav +0 -0
  21. package/packs/fighting-announcer/sounds/sudden-death.wav +0 -0
  22. package/packs/fighting-announcer/sounds/victory.wav +0 -0
  23. package/packs/fighting-announcer/sounds/you-lose.wav +0 -0
  24. package/packs/fighting-announcer/sounds/you-win.wav +0 -0
  25. package/packs/wesnoth-combat/manifest.json +1 -1
  26. package/packs/xonotic-announcer/manifest.json +52 -0
  27. package/packs/xonotic-announcer/sounds/1fragleft.wav +0 -0
  28. package/packs/xonotic-announcer/sounds/airshot.wav +0 -0
  29. package/packs/xonotic-announcer/sounds/amazing.wav +0 -0
  30. package/packs/xonotic-announcer/sounds/awesome.wav +0 -0
  31. package/packs/xonotic-announcer/sounds/begin.wav +0 -0
  32. package/packs/xonotic-announcer/sounds/botlike.wav +0 -0
  33. package/packs/xonotic-announcer/sounds/electrobitch.wav +0 -0
  34. package/packs/xonotic-announcer/sounds/headshot.wav +0 -0
  35. package/packs/xonotic-announcer/sounds/impressive.wav +0 -0
  36. package/packs/xonotic-announcer/sounds/lastsecond.wav +0 -0
  37. package/packs/xonotic-announcer/sounds/leadgained.wav +0 -0
  38. package/packs/xonotic-announcer/sounds/narrowly.wav +0 -0
  39. package/packs/xonotic-announcer/sounds/prepareforbattle.wav +0 -0
  40. package/packs/xonotic-announcer/sounds/terminated.wav +0 -0
  41. package/packs/xonotic-announcer/sounds/yoda.wav +0 -0
  42. package/src/cli/completions.js +182 -0
  43. package/src/cli/config.js +32 -3
  44. package/src/cli/create.js +1 -1
  45. package/src/cli/doctor.js +134 -0
  46. package/src/cli/init.js +11 -8
  47. package/src/cli/play.js +22 -1
  48. package/src/cli/random-pack.js +44 -0
  49. package/src/cli/select.js +36 -32
  50. package/src/cli/uninstall.js +5 -0
  51. package/src/config.js +30 -0
  52. package/src/player.js +17 -12
package/README.md CHANGED
@@ -137,8 +137,11 @@ For different sounds based on what Claude is doing, set up multiple hooks:
137
137
  | `pingthings browse [category]` | Browse packs by category |
138
138
  | `pingthings search <term>` | Search packs and sounds |
139
139
  | `pingthings sounds [pack]` | List individual sounds in a pack |
140
+ | `pingthings random-pack` | Switch to a random pack |
140
141
  | `pingthings install <source>` | Install a pack from GitHub or local path |
141
142
  | `pingthings uninstall <pack>` | Remove a user-installed pack |
143
+ | `pingthings doctor` | Diagnose audio setup and configuration |
144
+ | `pingthings completions <shell>` | Generate shell completions (bash/zsh/fish) |
142
145
 
143
146
  ## Configuration
144
147
 
@@ -231,6 +234,12 @@ Clean modern UI notification sounds by **Kenney** — 18 sounds including confir
231
234
  ### kenney-scifi
232
235
  Futuristic sci-fi notification sounds by **Kenney** — 18 sounds including computer noises, force fields, lasers, explosions, and thrusters. License: CC0.
233
236
 
237
+ ### xonotic-announcer
238
+ Arena FPS announcer voice lines from **Xonotic** — 15 sounds including "awesome!", "amazing!", "impressive!", "prepare for battle!", "terminated!". License: GPL v2+.
239
+
240
+ ### fighting-announcer
241
+ Fighting game announcer voice lines — 20 sounds including "Fight!", "Victory!", "K.O!", "Game Over!", "Ready?", "You Win!". License: CC-BY 4.0.
242
+
234
243
  ## Custom packs
235
244
 
236
245
  Place packs in `~/.config/pingthings/packs/<pack-name>/`:
@@ -291,6 +300,9 @@ pingthings theme reset # back to defaults
291
300
  | `arena` | Arena announcer with FPS weapons for errors |
292
301
  | `fantasy` | Medieval fantasy — Wesnoth + 0 A.D. civilizations |
293
302
  | `ancient` | Ancient world — 7kaa soldiers + 0 A.D. voices |
303
+ | `professional` | Clean and minimal — Kenney UI sounds for everything |
304
+ | `8bit` | Pure retro — 8-bit chiptune for everything |
305
+ | `space` | Space station — Warzone 2100 + Kenney sci-fi |
294
306
  | `chaos` | Different pack for every event — maximum variety |
295
307
 
296
308
  ## Tools
package/bin/pingthings.js CHANGED
@@ -26,6 +26,9 @@ const commands = {
26
26
  create: () => import('../src/cli/create.js'),
27
27
  theme: () => import('../src/cli/theme.js'),
28
28
  'test-events': () => import('../src/cli/test-events.js'),
29
+ 'random-pack': () => import('../src/cli/random-pack.js'),
30
+ doctor: () => import('../src/cli/doctor.js'),
31
+ completions: () => import('../src/cli/completions.js'),
29
32
  };
30
33
 
31
34
  function showHelp() {
@@ -47,9 +50,12 @@ Commands:
47
50
  theme [name] Apply a sound theme (maps events across packs)
48
51
  config [key] [val] Show or update configuration
49
52
  init Set up Claude Code hooks automatically
53
+ random-pack Switch to a random pack
50
54
  create <dir> Create a new pack from a folder of audio files
51
55
  install <source> Install a pack from GitHub or URL
52
56
  uninstall <pack> Remove a user-installed pack
57
+ doctor Diagnose audio setup and configuration
58
+ completions <shell> Generate shell completions (bash/zsh/fish)
53
59
 
54
60
  Options:
55
61
  --help, -h Show this help message
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pingthings",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Notification sounds for Claude Code and other CLI tools",
5
5
  "type": "module",
6
6
  "license": "GPL-2.0",
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "fighting-announcer",
3
+ "description": "Fighting game announcer voice lines",
4
+ "version": "1.0.0",
5
+ "license": "CC-BY-4.0",
6
+ "credits": "Alba MacKenna (https://opengameart.org/content/voice-pack%E2%94%82fighting-game-announcer)",
7
+ "category": "arena",
8
+ "sounds": [
9
+ "sounds/begin.wav",
10
+ "sounds/complete.wav",
11
+ "sounds/congratulations.wav",
12
+ "sounds/defeat.wav",
13
+ "sounds/failure.wav",
14
+ "sounds/fight.wav",
15
+ "sounds/final-round.wav",
16
+ "sounds/finish.wav",
17
+ "sounds/game-over.wav",
18
+ "sounds/get-set.wav",
19
+ "sounds/go.wav",
20
+ "sounds/great.wav",
21
+ "sounds/high-score.wav",
22
+ "sounds/new-record.wav",
23
+ "sounds/no-contest.wav",
24
+ "sounds/ready.wav",
25
+ "sounds/sudden-death.wav",
26
+ "sounds/victory.wav",
27
+ "sounds/you-lose.wav",
28
+ "sounds/you-win.wav"
29
+ ],
30
+ "events": {
31
+ "done": [
32
+ "sounds/complete.wav",
33
+ "sounds/great.wav",
34
+ "sounds/finish.wav",
35
+ "sounds/new-record.wav"
36
+ ],
37
+ "permission": [
38
+ "sounds/fight.wav",
39
+ "sounds/ready.wav",
40
+ "sounds/get-set.wav",
41
+ "sounds/begin.wav",
42
+ "sounds/go.wav"
43
+ ],
44
+ "complete": [
45
+ "sounds/victory.wav",
46
+ "sounds/you-win.wav",
47
+ "sounds/congratulations.wav",
48
+ "sounds/high-score.wav"
49
+ ],
50
+ "error": [
51
+ "sounds/game-over.wav",
52
+ "sounds/defeat.wav",
53
+ "sounds/you-lose.wav",
54
+ "sounds/failure.wav"
55
+ ],
56
+ "blocked": [
57
+ "sounds/sudden-death.wav",
58
+ "sounds/final-round.wav",
59
+ "sounds/no-contest.wav"
60
+ ]
61
+ }
62
+ }
@@ -2,7 +2,7 @@
2
2
  "name": "wesnoth-combat",
3
3
  "description": "Fantasy combat and magic sounds from Battle for Wesnoth",
4
4
  "version": "1.0.0",
5
- "license": "GPL-2.0",
5
+ "license": "GPL-2.0-or-later",
6
6
  "credits": "Battle for Wesnoth contributors (https://github.com/wesnoth/wesnoth) — licensed under GPL v2+",
7
7
  "category": "fantasy",
8
8
  "sounds": [
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "xonotic-announcer",
3
+ "description": "Arena FPS announcer voice lines from Xonotic",
4
+ "version": "1.0.0",
5
+ "license": "GPL-2.0-or-later",
6
+ "credits": "Xonotic Project (https://github.com/xonotic/xonotic-data.pk3dir)",
7
+ "category": "arena",
8
+ "sounds": [
9
+ "sounds/1fragleft.wav",
10
+ "sounds/airshot.wav",
11
+ "sounds/amazing.wav",
12
+ "sounds/awesome.wav",
13
+ "sounds/begin.wav",
14
+ "sounds/botlike.wav",
15
+ "sounds/electrobitch.wav",
16
+ "sounds/headshot.wav",
17
+ "sounds/impressive.wav",
18
+ "sounds/lastsecond.wav",
19
+ "sounds/leadgained.wav",
20
+ "sounds/narrowly.wav",
21
+ "sounds/prepareforbattle.wav",
22
+ "sounds/terminated.wav",
23
+ "sounds/yoda.wav"
24
+ ],
25
+ "events": {
26
+ "done": [
27
+ "sounds/awesome.wav",
28
+ "sounds/amazing.wav",
29
+ "sounds/impressive.wav",
30
+ "sounds/airshot.wav"
31
+ ],
32
+ "permission": [
33
+ "sounds/prepareforbattle.wav",
34
+ "sounds/begin.wav",
35
+ "sounds/1fragleft.wav"
36
+ ],
37
+ "complete": [
38
+ "sounds/yoda.wav",
39
+ "sounds/botlike.wav",
40
+ "sounds/leadgained.wav"
41
+ ],
42
+ "error": [
43
+ "sounds/terminated.wav",
44
+ "sounds/headshot.wav",
45
+ "sounds/electrobitch.wav"
46
+ ],
47
+ "blocked": [
48
+ "sounds/lastsecond.wav",
49
+ "sounds/narrowly.wav"
50
+ ]
51
+ }
52
+ }
@@ -0,0 +1,182 @@
1
+ function showHelp() {
2
+ console.log(`
3
+ Usage: pingthings completions <shell>
4
+
5
+ Generate shell completion script.
6
+
7
+ Arguments:
8
+ shell bash, zsh, or fish
9
+
10
+ Install:
11
+ pingthings completions bash >> ~/.bashrc
12
+ pingthings completions zsh >> ~/.zshrc
13
+ pingthings completions fish > ~/.config/fish/completions/pingthings.fish
14
+ `);
15
+ }
16
+
17
+ const COMMANDS = [
18
+ 'play', 'list', 'select', 'browse', 'search', 'sounds',
19
+ 'use', 'preview', 'test-events', 'theme', 'config',
20
+ 'init', 'create', 'install', 'uninstall', 'random-pack', 'doctor', 'completions',
21
+ ];
22
+
23
+ const THEMES = [
24
+ 'retro', 'sci-fi', 'arena', 'fantasy', 'ancient',
25
+ 'professional', '8bit', 'space', 'chaos', 'reset',
26
+ ];
27
+
28
+ const EVENTS = ['done', 'permission', 'complete', 'error', 'blocked'];
29
+
30
+ function bash() {
31
+ return `# pingthings bash completions
32
+ _pingthings() {
33
+ local cur prev commands
34
+ COMPREPLY=()
35
+ cur="\${COMP_WORDS[COMP_CWORD]}"
36
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
37
+ commands="${COMMANDS.join(' ')}"
38
+
39
+ case "\${prev}" in
40
+ pingthings)
41
+ COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
42
+ return 0
43
+ ;;
44
+ use|preview|sounds|test-events|uninstall)
45
+ local packs=$(pingthings list 2>/dev/null | grep -oE '\\S+-\\S+' | head -20)
46
+ COMPREPLY=( $(compgen -W "\${packs}" -- "\${cur}") )
47
+ return 0
48
+ ;;
49
+ theme)
50
+ COMPREPLY=( $(compgen -W "${THEMES.join(' ')}" -- "\${cur}") )
51
+ return 0
52
+ ;;
53
+ --event|-e)
54
+ COMPREPLY=( $(compgen -W "${EVENTS.join(' ')}" -- "\${cur}") )
55
+ return 0
56
+ ;;
57
+ browse)
58
+ local cats="military arena fantasy sci-fi fps retro ui transport spooky racing"
59
+ COMPREPLY=( $(compgen -W "\${cats}" -- "\${cur}") )
60
+ return 0
61
+ ;;
62
+ completions)
63
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
64
+ return 0
65
+ ;;
66
+ esac
67
+
68
+ return 0
69
+ }
70
+ complete -F _pingthings pingthings`;
71
+ }
72
+
73
+ function zsh() {
74
+ return `# pingthings zsh completions
75
+ _pingthings() {
76
+ local -a commands themes events
77
+ commands=(
78
+ 'play:Play a sound from the active pack'
79
+ 'list:Show available sound packs'
80
+ 'select:Interactive pack selector'
81
+ 'browse:Browse packs by category'
82
+ 'search:Search packs and sounds'
83
+ 'sounds:List individual sounds in a pack'
84
+ 'use:Set the active sound pack'
85
+ 'preview:Preview a random sound from a pack'
86
+ 'test-events:Play all event sounds'
87
+ 'theme:Apply a sound theme'
88
+ 'config:Show or update configuration'
89
+ 'init:Set up Claude Code hooks'
90
+ 'create:Create a pack from audio files'
91
+ 'install:Install a pack from GitHub or local path'
92
+ 'uninstall:Remove a user-installed pack'
93
+ 'random-pack:Switch to a random pack'
94
+ 'doctor:Diagnose audio setup'
95
+ 'completions:Generate shell completions'
96
+ )
97
+ themes=(${THEMES.join(' ')})
98
+ events=(${EVENTS.join(' ')})
99
+
100
+ _arguments '1:command:->cmds' '*::arg:->args'
101
+
102
+ case "$state" in
103
+ cmds)
104
+ _describe -t commands 'pingthings command' commands
105
+ ;;
106
+ args)
107
+ case $words[1] in
108
+ use|preview|sounds|test-events|uninstall)
109
+ local packs=($(pingthings list 2>/dev/null | grep -oE '\\S+-\\S+' | head -20))
110
+ _describe -t packs 'pack' packs
111
+ ;;
112
+ theme)
113
+ _describe -t themes 'theme' themes
114
+ ;;
115
+ browse)
116
+ local cats=(military arena fantasy sci-fi fps retro ui transport spooky racing)
117
+ _describe -t categories 'category' cats
118
+ ;;
119
+ completions)
120
+ _describe -t shells 'shell' '(bash zsh fish)'
121
+ ;;
122
+ esac
123
+ ;;
124
+ esac
125
+ }
126
+ compdef _pingthings pingthings`;
127
+ }
128
+
129
+ function fish() {
130
+ const lines = [
131
+ '# pingthings fish completions',
132
+ 'complete -c pingthings -f',
133
+ ];
134
+
135
+ const cmdDescs = {
136
+ play: 'Play a sound', list: 'Show packs', select: 'Interactive picker',
137
+ browse: 'Browse by category', search: 'Search packs', sounds: 'List sounds',
138
+ use: 'Set active pack', preview: 'Preview a pack', 'test-events': 'Test event sounds',
139
+ theme: 'Apply a theme', config: 'Configuration', init: 'Set up hooks',
140
+ create: 'Create a pack', install: 'Install a pack', uninstall: 'Remove a pack',
141
+ 'random-pack': 'Random pack', doctor: 'Diagnose setup', completions: 'Shell completions',
142
+ };
143
+
144
+ for (const [cmd, desc] of Object.entries(cmdDescs)) {
145
+ lines.push(`complete -c pingthings -n '__fish_use_subcommand' -a '${cmd}' -d '${desc}'`);
146
+ }
147
+
148
+ for (const t of THEMES) {
149
+ lines.push(`complete -c pingthings -n '__fish_seen_subcommand_from theme' -a '${t}'`);
150
+ }
151
+
152
+ for (const e of EVENTS) {
153
+ lines.push(`complete -c pingthings -n '__fish_seen_subcommand_from play' -l 'event' -a '${e}'`);
154
+ }
155
+
156
+ return lines.join('\n');
157
+ }
158
+
159
+ export default function completions(args) {
160
+ const shell = args[0];
161
+
162
+ if (!shell || shell === '--help' || shell === '-h') {
163
+ showHelp();
164
+ if (!shell) process.exit(1);
165
+ return;
166
+ }
167
+
168
+ switch (shell) {
169
+ case 'bash':
170
+ console.log(bash());
171
+ break;
172
+ case 'zsh':
173
+ console.log(zsh());
174
+ break;
175
+ case 'fish':
176
+ console.log(fish());
177
+ break;
178
+ default:
179
+ console.error(`Unknown shell: ${shell}. Use bash, zsh, or fish.`);
180
+ process.exit(1);
181
+ }
182
+ }
package/src/cli/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readConfig, writeConfig, VALID_MODES, VALID_EVENTS } from '../config.js';
2
2
 
3
- const VALID_KEYS = ['activePack', 'mode', 'specificSound', 'volume', 'eventPacks'];
3
+ const VALID_KEYS = ['activePack', 'mode', 'specificSound', 'volume', 'eventPacks', 'cooldown', 'quietHours'];
4
4
 
5
5
  function showHelp() {
6
6
  console.log(`
@@ -17,13 +17,16 @@ Keys:
17
17
  mode random, specific, or informational
18
18
  specificSound Sound name for specific mode
19
19
  volume Playback volume (0-100)
20
+ cooldown Avoid repeating the same sound (true/false)
21
+ quietHours Mute during hours, e.g. "22-7" (10pm-7am)
20
22
  eventPacks Per-event pack overrides (use "pingthings config eventPacks.<event> <pack>")
21
23
 
22
24
  Examples:
23
25
  pingthings config Show full config
24
- pingthings config volume Show current volume
25
26
  pingthings config volume 50 Set volume to 50%
26
- pingthings config mode informational Enable event-based sounds
27
+ pingthings config cooldown false Disable cooldown
28
+ pingthings config quietHours 22-7 Mute 10pm to 7am
29
+ pingthings config quietHours null Disable quiet hours
27
30
  pingthings config eventPacks.error openarena-announcer
28
31
  `);
29
32
  }
@@ -109,6 +112,32 @@ export default function config(args) {
109
112
  return;
110
113
  }
111
114
 
115
+ // Validate cooldown
116
+ if (key === 'cooldown') {
117
+ if (value !== 'true' && value !== 'false') {
118
+ console.error('Cooldown must be true or false.');
119
+ process.exit(1);
120
+ }
121
+ const cfg = readConfig();
122
+ cfg.cooldown = value === 'true';
123
+ writeConfig(cfg);
124
+ console.log(`cooldown set to: ${cfg.cooldown}`);
125
+ return;
126
+ }
127
+
128
+ // Validate quietHours
129
+ if (key === 'quietHours') {
130
+ if (value !== 'null' && !/^\d{1,2}-\d{1,2}$/.test(value)) {
131
+ console.error('Quiet hours must be in format "HH-HH" (e.g., "22-7") or "null" to disable.');
132
+ process.exit(1);
133
+ }
134
+ const cfg = readConfig();
135
+ cfg.quietHours = value === 'null' ? null : value;
136
+ writeConfig(cfg);
137
+ console.log(`quietHours set to: ${cfg.quietHours ?? '(disabled)'}`);
138
+ return;
139
+ }
140
+
112
141
  // Two args: set value
113
142
  const cfg = readConfig();
114
143
  cfg[key] = value === 'null' ? null : value;
package/src/cli/create.js CHANGED
@@ -24,7 +24,7 @@ Examples:
24
24
  `);
25
25
  }
26
26
 
27
- export default async function create(args) {
27
+ export default function create(args) {
28
28
  if (args.includes('--help') || args.includes('-h')) {
29
29
  showHelp();
30
30
  return;
@@ -0,0 +1,134 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { platform, homedir } from 'node:os';
5
+ import { readConfig, getConfigDir } from '../config.js';
6
+ import { listPacks } from '../packs.js';
7
+
8
+ function showHelp() {
9
+ console.log(`
10
+ Usage: pingthings doctor
11
+
12
+ Diagnose audio setup, check player availability, verify packs,
13
+ and validate Claude Code hook configuration.
14
+ `);
15
+ }
16
+
17
+ function check(label, fn) {
18
+ try {
19
+ const result = fn();
20
+ if (result === true) {
21
+ console.log(` OK ${label}`);
22
+ } else if (result === false) {
23
+ console.log(` !! ${label}`);
24
+ } else {
25
+ console.log(` OK ${label}: ${result}`);
26
+ }
27
+ return result;
28
+ } catch (err) {
29
+ console.log(` !! ${label}: ${err.message}`);
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function commandExists(cmd) {
35
+ try {
36
+ execFileSync('which', [cmd], { stdio: 'pipe' });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export default function doctor(args) {
44
+ if (args.includes('--help') || args.includes('-h')) {
45
+ showHelp();
46
+ return;
47
+ }
48
+
49
+ console.log('\npingthings doctor\n');
50
+
51
+ // Platform
52
+ console.log('Platform:');
53
+ check('Operating system', () => {
54
+ const os = platform();
55
+ const names = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
56
+ return names[os] || os;
57
+ });
58
+
59
+ // Audio player
60
+ console.log('\nAudio player:');
61
+ let hasPlayer = false;
62
+ if (platform() === 'darwin') {
63
+ hasPlayer = check('afplay available', () => commandExists('afplay'));
64
+ } else if (platform() === 'linux') {
65
+ const pa = check('paplay available (PulseAudio/PipeWire)', () => commandExists('paplay'));
66
+ const al = check('aplay available (ALSA)', () => commandExists('aplay'));
67
+ hasPlayer = pa || al;
68
+ } else if (platform() === 'win32') {
69
+ hasPlayer = check('PowerShell available', () => commandExists('powershell'));
70
+ }
71
+ if (!hasPlayer) {
72
+ console.log('\n No audio player found! Sounds will not play.');
73
+ if (platform() === 'linux') {
74
+ console.log(' Install: sudo apt install pulseaudio-utils (Ubuntu/Debian)');
75
+ console.log(' Or: sudo apt install alsa-utils');
76
+ }
77
+ }
78
+
79
+ // Config
80
+ console.log('\nConfiguration:');
81
+ const config = readConfig();
82
+ check('Config directory', () => getConfigDir());
83
+ check('Active pack', () => config.activePack);
84
+ check('Mode', () => config.mode);
85
+ check('Volume', () => `${config.volume}%`);
86
+ if (config.quietHours) {
87
+ check('Quiet hours', () => config.quietHours);
88
+ }
89
+
90
+ // Packs
91
+ console.log('\nPacks:');
92
+ const packs = listPacks();
93
+ check('Built-in packs', () => {
94
+ const builtIn = packs.filter(p => p.isBuiltIn);
95
+ return `${builtIn.length} packs, ${builtIn.reduce((s, p) => s + p.soundCount, 0)} sounds`;
96
+ });
97
+ check('User packs', () => {
98
+ const user = packs.filter(p => !p.isBuiltIn);
99
+ return user.length > 0 ? `${user.length} packs` : 'none';
100
+ });
101
+
102
+ // Active pack validation
103
+ const activePack = packs.find(p => p.name === config.activePack);
104
+ if (!activePack) {
105
+ check('Active pack exists', () => { throw new Error(`"${config.activePack}" not found`); });
106
+ } else {
107
+ check('Active pack valid', () => `${activePack.name} (${activePack.soundCount} sounds)`);
108
+ }
109
+
110
+ // Claude Code hooks
111
+ console.log('\nClaude Code integration:');
112
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
113
+ if (existsSync(settingsPath)) {
114
+ try {
115
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
116
+ const hooks = settings.hooks || {};
117
+ const hasPingthings = JSON.stringify(hooks).includes('pingthings');
118
+ check('settings.json found', () => true);
119
+ check('pingthings hooks configured', () => hasPingthings);
120
+ if (hasPingthings) {
121
+ const hookTypes = Object.keys(hooks).filter(k =>
122
+ JSON.stringify(hooks[k]).includes('pingthings')
123
+ );
124
+ check('Hook events', () => hookTypes.join(', '));
125
+ }
126
+ } catch {
127
+ check('settings.json valid', () => false);
128
+ }
129
+ } else {
130
+ check('settings.json found', () => { throw new Error('Run "pingthings init" to set up'); });
131
+ }
132
+
133
+ console.log('');
134
+ }
package/src/cli/init.js CHANGED
@@ -113,13 +113,16 @@ export default async function init(args) {
113
113
  output: process.stdout,
114
114
  });
115
115
 
116
- rl.question('Choose (1 or 2): ', (answer) => {
117
- const choice = answer.trim();
118
- if (choice === '2') {
119
- applyHooks('informational');
120
- } else {
121
- applyHooks('basic');
122
- }
123
- rl.close();
116
+ await new Promise((resolve) => {
117
+ rl.question('Choose (1 or 2): ', (answer) => {
118
+ const choice = answer.trim();
119
+ if (choice === '2') {
120
+ applyHooks('informational');
121
+ } else {
122
+ applyHooks('basic');
123
+ }
124
+ rl.close();
125
+ resolve();
126
+ });
124
127
  });
125
128
  }
package/src/cli/play.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readConfig, VALID_EVENTS } from '../config.js';
1
+ import { readConfig, VALID_EVENTS, getLastPlayed, setLastPlayed, isQuietHours } from '../config.js';
2
2
  import { getPackSounds, getEventSounds, pickRandom, resolvePack } from '../packs.js';
3
3
  import { playSound } from '../player.js';
4
4
  import { basename } from 'node:path';
@@ -133,5 +133,26 @@ export default function play(args) {
133
133
  process.exit(1);
134
134
  }
135
135
 
136
+ // Quiet hours check
137
+ if (isQuietHours(config)) {
138
+ return;
139
+ }
140
+
141
+ // Cooldown: avoid playing the same sound twice in a row
142
+ if (config.cooldown && !parsed.sound) {
143
+ const lastPlayed = getLastPlayed();
144
+ if (soundFile === lastPlayed) {
145
+ // Pick a different sound from the same pool
146
+ const pool = parsed.event
147
+ ? getEventSounds(config.eventPacks?.[parsed.event] || packName, parsed.event)
148
+ : getPackSounds(packName);
149
+ if (pool.length > 1) {
150
+ const alternatives = pool.filter(s => s !== lastPlayed);
151
+ soundFile = pickRandom(alternatives);
152
+ }
153
+ }
154
+ }
155
+
156
+ setLastPlayed(soundFile);
136
157
  playSound(soundFile, config.volume);
137
158
  }
@@ -0,0 +1,44 @@
1
+ import { readConfig, writeConfig } from '../config.js';
2
+ import { listPacks, getPackSounds, pickRandom } from '../packs.js';
3
+ import { playSound } from '../player.js';
4
+ import { basename } from 'node:path';
5
+
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings random-pack
9
+
10
+ Switch to a random pack for variety. Plays a preview sound from the new pack.
11
+ `);
12
+ }
13
+
14
+ export default function randomPack(args) {
15
+ if (args.includes('--help') || args.includes('-h')) {
16
+ showHelp();
17
+ return;
18
+ }
19
+
20
+ const config = readConfig();
21
+ const packs = listPacks();
22
+
23
+ if (packs.length <= 1) {
24
+ console.log('Only one pack available.');
25
+ return;
26
+ }
27
+
28
+ // Pick a different pack than the current one
29
+ const others = packs.filter(p => p.name !== config.activePack);
30
+ const chosen = others[Math.floor(Math.random() * others.length)];
31
+
32
+ config.activePack = chosen.name;
33
+ writeConfig(config);
34
+
35
+ const sounds = getPackSounds(chosen.name);
36
+ if (sounds.length > 0) {
37
+ const sample = pickRandom(sounds);
38
+ console.log(`Switched to: ${chosen.name} (${chosen.category})`);
39
+ console.log(`Preview: ${basename(sample)}`);
40
+ playSound(sample, config.volume);
41
+ } else {
42
+ console.log(`Switched to: ${chosen.name}`);
43
+ }
44
+ }
package/src/cli/select.js CHANGED
@@ -45,37 +45,41 @@ export default async function select(args) {
45
45
  output: process.stdout,
46
46
  });
47
47
 
48
- const ask = () => {
49
- rl.question('> ', (answer) => {
50
- const trimmed = answer.trim().toLowerCase();
51
-
52
- if (trimmed === 'q' || trimmed === 'quit' || trimmed === '') {
48
+ await new Promise((resolve) => {
49
+ const ask = () => {
50
+ rl.question('> ', (answer) => {
51
+ const trimmed = answer.trim().toLowerCase();
52
+
53
+ if (trimmed === 'q' || trimmed === 'quit' || trimmed === '') {
54
+ rl.close();
55
+ resolve();
56
+ return;
57
+ }
58
+
59
+ const num = parseInt(trimmed, 10);
60
+ if (isNaN(num) || num < 1 || num > packs.length) {
61
+ console.log(`Enter 1-${packs.length} or q to quit.`);
62
+ ask();
63
+ return;
64
+ }
65
+
66
+ const chosen = packs[num - 1];
67
+ config.activePack = chosen.name;
68
+ writeConfig(config);
69
+
70
+ // Preview a sound from the chosen pack
71
+ const sounds = getPackSounds(chosen.name);
72
+ if (sounds.length > 0) {
73
+ const sample = sounds[Math.floor(Math.random() * sounds.length)];
74
+ playSound(sample, config.volume);
75
+ }
76
+
77
+ console.log(`\nActive pack set to: ${chosen.name}`);
53
78
  rl.close();
54
- return;
55
- }
56
-
57
- const num = parseInt(trimmed, 10);
58
- if (isNaN(num) || num < 1 || num > packs.length) {
59
- console.log(`Enter 1-${packs.length} or q to quit.`);
60
- ask();
61
- return;
62
- }
63
-
64
- const chosen = packs[num - 1];
65
- config.activePack = chosen.name;
66
- writeConfig(config);
67
-
68
- // Preview a sound from the chosen pack
69
- const sounds = getPackSounds(chosen.name);
70
- if (sounds.length > 0) {
71
- const sample = sounds[Math.floor(Math.random() * sounds.length)];
72
- playSound(sample, config.volume);
73
- }
74
-
75
- console.log(`\nActive pack set to: ${chosen.name}`);
76
- rl.close();
77
- });
78
- };
79
-
80
- ask();
79
+ resolve();
80
+ });
81
+ };
82
+
83
+ ask();
84
+ });
81
85
  }
@@ -25,6 +25,11 @@ export default function uninstall(args) {
25
25
  return;
26
26
  }
27
27
 
28
+ if (packName.includes('..') || packName.includes('/') || packName.includes('\\')) {
29
+ console.error('Invalid pack name.');
30
+ process.exit(1);
31
+ }
32
+
28
33
  const userPackDir = join(getConfigDir(), 'packs', packName);
29
34
 
30
35
  if (!existsSync(userPackDir)) {
package/src/config.js CHANGED
@@ -8,6 +8,8 @@ const DEFAULTS = {
8
8
  specificSound: null,
9
9
  volume: 100,
10
10
  eventPacks: {},
11
+ cooldown: true,
12
+ quietHours: null,
11
13
  };
12
14
 
13
15
  export function getConfigDir() {
@@ -45,6 +47,34 @@ export function getDefaults() {
45
47
  return { ...DEFAULTS };
46
48
  }
47
49
 
50
+ export function getLastPlayed() {
51
+ const path = join(getConfigDir(), '.last-played');
52
+ try {
53
+ return readFileSync(path, 'utf8').trim();
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function setLastPlayed(soundPath) {
60
+ const path = join(getConfigDir(), '.last-played');
61
+ try {
62
+ writeFileSync(path, soundPath, 'utf8');
63
+ } catch {}
64
+ }
65
+
66
+ export function isQuietHours(config) {
67
+ if (!config.quietHours) return false;
68
+ const [start, end] = config.quietHours.split('-').map(Number);
69
+ if (isNaN(start) || isNaN(end)) return false;
70
+ const hour = new Date().getHours();
71
+ if (start < end) {
72
+ return hour >= start && hour < end;
73
+ }
74
+ // Wraps midnight (e.g., 22-7)
75
+ return hour >= start || hour < end;
76
+ }
77
+
48
78
  export const VALID_MODES = ['random', 'specific', 'informational'];
49
79
 
50
80
  export const VALID_EVENTS = ['done', 'permission', 'complete', 'error', 'blocked'];
package/src/player.js CHANGED
@@ -2,17 +2,23 @@ import { spawn, execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { platform } from 'node:os';
4
4
 
5
+ function commandExists(cmd) {
6
+ try {
7
+ execFileSync('which', [cmd], { stdio: 'pipe' });
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
5
14
  function getPlayerCommand() {
6
15
  switch (platform()) {
7
16
  case 'darwin':
8
17
  return 'afplay';
9
18
  case 'linux': {
10
- try {
11
- execFileSync('which', ['paplay'], { stdio: 'pipe' });
12
- return 'paplay';
13
- } catch {
14
- return 'aplay';
15
- }
19
+ if (commandExists('paplay')) return 'paplay';
20
+ if (commandExists('aplay')) return 'aplay';
21
+ return null;
16
22
  }
17
23
  case 'win32':
18
24
  return 'powershell';
@@ -26,20 +32,16 @@ function buildArgs(cmd, filePath, volume) {
26
32
 
27
33
  switch (cmd) {
28
34
  case 'afplay': {
29
- // afplay volume: 0.0 to 1.0
30
35
  const afplayVol = (vol / 100).toFixed(2);
31
36
  return ['-v', afplayVol, filePath];
32
37
  }
33
38
  case 'paplay': {
34
- // paplay volume: 0 to 65536 (100% = 65536)
35
39
  const paplayVol = Math.round((vol / 100) * 65536).toString();
36
40
  return ['--volume', paplayVol, filePath];
37
41
  }
38
42
  case 'aplay':
39
- // aplay doesn't support volume natively
40
43
  return [filePath];
41
44
  case 'powershell': {
42
- // PowerShell SoundPlayer doesn't support volume, but it plays the file
43
45
  const script = `(New-Object System.Media.SoundPlayer '${filePath.replace(/'/g, "''")}').PlaySync()`;
44
46
  return ['-NoProfile', '-Command', script];
45
47
  }
@@ -55,7 +57,8 @@ export function playSound(filePath, volume) {
55
57
 
56
58
  const cmd = getPlayerCommand();
57
59
  if (!cmd) {
58
- throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux, Windows.`);
60
+ // No audio player available — skip silently (e.g. CI environments)
61
+ return;
59
62
  }
60
63
 
61
64
  const args = buildArgs(cmd, filePath, volume);
@@ -65,6 +68,8 @@ export function playSound(filePath, volume) {
65
68
  stdio: 'ignore',
66
69
  });
67
70
 
71
+ // Handle spawn errors gracefully (command not found, permission denied)
72
+ child.on('error', () => {});
68
73
  child.unref();
69
74
  }
70
75
 
@@ -75,7 +80,7 @@ export function playSoundSync(filePath, volume) {
75
80
 
76
81
  const cmd = getPlayerCommand();
77
82
  if (!cmd) {
78
- throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux, Windows.`);
83
+ return;
79
84
  }
80
85
 
81
86
  const args = buildArgs(cmd, filePath, volume);