pingthings 0.1.0 → 0.2.1

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/LICENSE CHANGED
@@ -6,6 +6,25 @@
6
6
  Everyone is permitted to copy and distribute verbatim copies
7
7
  of this license document, but changing it is not allowed.
8
8
 
9
- Audio assets are from Seven Kingdoms: Ancient Adversaries
10
- Copyright 1997,1998 Enlight Software Ltd.
11
- Licensed under GPL v2.
9
+ This project is licensed under GPL v2. Audio assets are sourced from
10
+ multiple open source projects under compatible licenses:
11
+
12
+ 7kaa-soldiers: Seven Kingdoms: Ancient Adversaries
13
+ Copyright 1997,1998 Enlight Software Ltd. — GPL v2
14
+
15
+ wesnoth-combat: Battle for Wesnoth
16
+ Copyright Wesnoth contributors — GPL v2+
17
+
18
+ openarena-announcer: OpenArena
19
+ Copyright OpenArena contributors — GPL v2
20
+
21
+ freedoom-arsenal: Freedoom
22
+ Copyright Freedoom contributors — BSD-3-Clause
23
+
24
+ warzone2100-command: Warzone 2100
25
+ Copyright Warzone 2100 Project — GPL v2
26
+
27
+ 0ad-civilizations: 0 A.D.
28
+ Copyright Wildfire Games — CC-BY-SA 3.0
29
+ Distributed as part of this aggregate work alongside GPL v2 code.
30
+ See packs/0ad-civilizations/manifest.json for full attribution.
package/README.md CHANGED
@@ -126,10 +126,15 @@ For different sounds based on what Claude is doing, set up multiple hooks:
126
126
  |---------|-------------|
127
127
  | `pingthings play [sound] [--event type]` | Play a sound (random, specific, or event-based) |
128
128
  | `pingthings list` | Show available sound packs |
129
+ | `pingthings select` | Interactive pack selector |
129
130
  | `pingthings use <pack>` | Set the active sound pack |
130
131
  | `pingthings preview <pack>` | Preview a random sound from a pack |
132
+ | `pingthings test-events [pack]` | Play all event sounds to hear each one |
133
+ | `pingthings theme [name]` | Apply a sound theme |
131
134
  | `pingthings config [key] [val]` | Show or update configuration |
132
- | `pingthings install <pack>` | Install a sound pack (coming soon) |
135
+ | `pingthings init` | Set up Claude Code hooks automatically |
136
+ | `pingthings create <dir>` | Create a pack from audio files |
137
+ | `pingthings install <source>` | Install a pack from GitHub or local path |
133
138
 
134
139
  ## Configuration
135
140
 
@@ -139,13 +144,17 @@ Config lives at `~/.config/pingthings/config.json`:
139
144
  {
140
145
  "activePack": "7kaa-soldiers",
141
146
  "mode": "random",
142
- "specificSound": null
147
+ "specificSound": null,
148
+ "volume": 100,
149
+ "eventPacks": {}
143
150
  }
144
151
  ```
145
152
 
146
153
  - **activePack** — which sound pack to use
147
154
  - **mode** — `"random"` (default), `"specific"`, or `"informational"`
148
155
  - **specificSound** — sound name to always play when mode is `"specific"`
156
+ - **volume** — playback volume, 0-100 (default: 100)
157
+ - **eventPacks** — per-event pack overrides (e.g. `{"error": "freedoom-arsenal"}`)
149
158
 
150
159
  Set values via CLI:
151
160
 
@@ -252,11 +261,87 @@ To support informational mode, add an `events` field mapping event types to soun
252
261
  }
253
262
  ```
254
263
 
264
+ ## Themes
265
+
266
+ Apply a pre-built theme that maps events to packs with one command:
267
+
268
+ ```bash
269
+ pingthings theme # list available themes
270
+ pingthings theme sci-fi # apply a theme
271
+ pingthings theme reset # back to defaults
272
+ ```
273
+
274
+ | Theme | Description |
275
+ |-------|-------------|
276
+ | `retro` | Classic retro gaming — Freedoom weapons + 7kaa soldiers |
277
+ | `sci-fi` | Sci-fi command center — Warzone 2100 commander voice |
278
+ | `arena` | Arena announcer with FPS weapons for errors |
279
+ | `fantasy` | Medieval fantasy — Wesnoth + 0 A.D. civilizations |
280
+ | `ancient` | Ancient world — 7kaa soldiers + 0 A.D. voices |
281
+ | `chaos` | Different pack for every event — maximum variety |
282
+
283
+ ## Tools
284
+
285
+ ### Create a pack
286
+ ```bash
287
+ pingthings create ./my-sounds my-pack
288
+ ```
289
+ Scaffolds a new pack from a folder of audio files with an auto-generated manifest.
290
+
291
+ ### Install from GitHub
292
+ ```bash
293
+ pingthings install user/repo
294
+ pingthings install https://github.com/user/pack-name
295
+ ```
296
+
297
+ ### Auto-setup Claude Code hooks
298
+ ```bash
299
+ pingthings init # interactive wizard
300
+ pingthings init --basic # random sounds, no prompts
301
+ pingthings init --informational # event-based sounds, no prompts
302
+ ```
303
+
304
+ ## Use with other tools
305
+
306
+ pingthings works anywhere you can run a shell command.
307
+
308
+ ### Git hooks
309
+ ```bash
310
+ # .git/hooks/post-commit
311
+ #!/bin/sh
312
+ pingthings play --event done
313
+ ```
314
+
315
+ ### CI notifications (GitHub Actions)
316
+ ```yaml
317
+ - name: Notify on failure
318
+ if: failure()
319
+ run: npx pingthings play --event error
320
+ ```
321
+
322
+ ### Shell aliases
323
+ ```bash
324
+ # Add to ~/.zshrc or ~/.bashrc
325
+ alias done='pingthings play --event complete'
326
+ alias oops='pingthings play --event error'
327
+ ```
328
+
329
+ ### Pomodoro timer
330
+ ```bash
331
+ sleep 1500 && pingthings play --event complete # 25 minute focus
332
+ ```
333
+
255
334
  ## Requirements
256
335
 
257
336
  - Node.js >= 18
258
- - macOS (`afplay`) or Linux (`paplay` / `aplay`)
337
+ - macOS (`afplay`), Linux (`paplay` / `aplay`), or Windows (PowerShell)
259
338
 
260
339
  ## License
261
340
 
262
- GPL v2 — includes audio from [Seven Kingdoms: Ancient Adversaries](https://github.com/the3dfxdude/7kaa) (GPL v2).
341
+ GPL v2 — includes audio from open source games:
342
+ - [Seven Kingdoms: Ancient Adversaries](https://github.com/the3dfxdude/7kaa) (GPL v2)
343
+ - [Battle for Wesnoth](https://github.com/wesnoth/wesnoth) (GPL v2+)
344
+ - [OpenArena](http://openarena.ws) (GPL v2)
345
+ - [Freedoom](https://github.com/freedoom/freedoom) (BSD-3-Clause)
346
+ - [Warzone 2100](https://github.com/Warzone2100/warzone2100) (GPL v2)
347
+ - [0 A.D.](https://github.com/0ad/0ad) (CC-BY-SA 3.0)
package/bin/pingthings.js CHANGED
@@ -14,9 +14,14 @@ const commands = {
14
14
  play: () => import('../src/cli/play.js'),
15
15
  list: () => import('../src/cli/list.js'),
16
16
  use: () => import('../src/cli/use.js'),
17
+ select: () => import('../src/cli/select.js'),
17
18
  preview: () => import('../src/cli/preview.js'),
18
19
  config: () => import('../src/cli/config.js'),
19
20
  install: () => import('../src/cli/install.js'),
21
+ init: () => import('../src/cli/init.js'),
22
+ create: () => import('../src/cli/create.js'),
23
+ theme: () => import('../src/cli/theme.js'),
24
+ 'test-events': () => import('../src/cli/test-events.js'),
20
25
  };
21
26
 
22
27
  function showHelp() {
@@ -28,10 +33,15 @@ Usage: pingthings <command> [options]
28
33
  Commands:
29
34
  play [sound] Play a sound from the active pack (random by default)
30
35
  list Show available sound packs
36
+ select Interactive pack selector
31
37
  use <pack> Set the active sound pack
32
38
  preview <pack> Preview a random sound from a pack
39
+ test-events [pack] Play all event sounds to hear what each one sounds like
40
+ theme [name] Apply a sound theme (maps events across packs)
33
41
  config [key] [val] Show or update configuration
34
- install <pack> Install a sound pack (coming soon)
42
+ init Set up Claude Code hooks automatically
43
+ create <dir> Create a new pack from a folder of audio files
44
+ install <source> Install a pack from GitHub or URL
35
45
 
36
46
  Options:
37
47
  --help, -h Show this help message
@@ -47,9 +57,11 @@ Examples:
47
57
  pingthings play 00083-READY Play a specific sound
48
58
  pingthings play --event done Play a "task done" sound
49
59
  pingthings play -e error Play an "error" sound
50
- pingthings list List all available packs
51
- pingthings use 7kaa-soldiers Switch to a pack
52
- pingthings config mode informational Enable event-based sounds
60
+ pingthings select Choose a pack interactively
61
+ pingthings test-events Hear all event sounds
62
+ pingthings theme retro Apply the retro theme
63
+ pingthings init Set up Claude Code hooks
64
+ pingthings config volume 50 Set volume to 50%
53
65
  `);
54
66
  }
55
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pingthings",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Notification sounds for Claude Code and other CLI tools",
5
5
  "type": "module",
6
6
  "license": "GPL-2.0",
package/src/cli/config.js CHANGED
@@ -1,8 +1,39 @@
1
- import { readConfig, writeConfig, VALID_MODES } from '../config.js';
1
+ import { readConfig, writeConfig, VALID_MODES, VALID_EVENTS } from '../config.js';
2
2
 
3
- const VALID_KEYS = ['activePack', 'mode', 'specificSound'];
3
+ const VALID_KEYS = ['activePack', 'mode', 'specificSound', 'volume', 'eventPacks'];
4
+
5
+ function showHelp() {
6
+ console.log(`
7
+ Usage: pingthings config [key] [value]
8
+
9
+ Show or update configuration.
10
+
11
+ With no arguments, shows the full config.
12
+ With one argument, shows that key's value.
13
+ With two arguments, sets the key to the value.
14
+
15
+ Keys:
16
+ activePack Which sound pack to use
17
+ mode random, specific, or informational
18
+ specificSound Sound name for specific mode
19
+ volume Playback volume (0-100)
20
+ eventPacks Per-event pack overrides (use "pingthings config eventPacks.<event> <pack>")
21
+
22
+ Examples:
23
+ pingthings config Show full config
24
+ pingthings config volume Show current volume
25
+ pingthings config volume 50 Set volume to 50%
26
+ pingthings config mode informational Enable event-based sounds
27
+ pingthings config eventPacks.error openarena-announcer
28
+ `);
29
+ }
4
30
 
5
31
  export default function config(args) {
32
+ if (args.includes('--help') || args.includes('-h')) {
33
+ showHelp();
34
+ return;
35
+ }
36
+
6
37
  const [key, ...rest] = args;
7
38
  const value = rest.join(' ') || undefined;
8
39
 
@@ -13,6 +44,31 @@ export default function config(args) {
13
44
  return;
14
45
  }
15
46
 
47
+ // Handle eventPacks.event syntax
48
+ if (key.startsWith('eventPacks.')) {
49
+ const event = key.split('.')[1];
50
+ if (!VALID_EVENTS.includes(event)) {
51
+ console.error(`Unknown event: ${event}`);
52
+ console.error(`Valid events: ${VALID_EVENTS.join(', ')}`);
53
+ process.exit(1);
54
+ }
55
+ if (value === undefined) {
56
+ const cfg = readConfig();
57
+ console.log(cfg.eventPacks?.[event] ?? '(not set — uses active pack)');
58
+ return;
59
+ }
60
+ const cfg = readConfig();
61
+ if (!cfg.eventPacks) cfg.eventPacks = {};
62
+ if (value === 'null' || value === 'reset') {
63
+ delete cfg.eventPacks[event];
64
+ } else {
65
+ cfg.eventPacks[event] = value;
66
+ }
67
+ writeConfig(cfg);
68
+ console.log(`${event} pack set to: ${value === 'null' || value === 'reset' ? '(active pack)' : value}`);
69
+ return;
70
+ }
71
+
16
72
  // Validate key
17
73
  if (!VALID_KEYS.includes(key)) {
18
74
  console.error(`Unknown config key: ${key}`);
@@ -23,7 +79,12 @@ export default function config(args) {
23
79
  // One arg: show value
24
80
  if (value === undefined) {
25
81
  const cfg = readConfig();
26
- console.log(cfg[key] ?? '(not set)');
82
+ const val = cfg[key];
83
+ if (typeof val === 'object' && val !== null) {
84
+ console.log(JSON.stringify(val, null, 2));
85
+ } else {
86
+ console.log(val ?? '(not set)');
87
+ }
27
88
  return;
28
89
  }
29
90
 
@@ -34,6 +95,20 @@ export default function config(args) {
34
95
  process.exit(1);
35
96
  }
36
97
 
98
+ // Validate volume
99
+ if (key === 'volume') {
100
+ const vol = parseInt(value, 10);
101
+ if (isNaN(vol) || vol < 0 || vol > 100) {
102
+ console.error('Volume must be a number between 0 and 100.');
103
+ process.exit(1);
104
+ }
105
+ const cfg = readConfig();
106
+ cfg.volume = vol;
107
+ writeConfig(cfg);
108
+ console.log(`volume set to: ${vol}`);
109
+ return;
110
+ }
111
+
37
112
  // Two args: set value
38
113
  const cfg = readConfig();
39
114
  cfg[key] = value === 'null' ? null : value;
@@ -0,0 +1,109 @@
1
+ import { existsSync, readdirSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs';
2
+ import { join, basename, extname } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { getConfigDir } from '../config.js';
5
+
6
+ const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.ogg', '.flac']);
7
+
8
+ function showHelp() {
9
+ console.log(`
10
+ Usage: pingthings create <source-dir> [pack-name]
11
+
12
+ Create a new sound pack from a folder of audio files.
13
+
14
+ Arguments:
15
+ source-dir Directory containing audio files (.wav, .mp3, .ogg, .flac)
16
+ pack-name Name for the pack (defaults to directory name)
17
+
18
+ The pack will be installed to ~/.config/pingthings/packs/<pack-name>/
19
+ with an auto-generated manifest.json.
20
+
21
+ Examples:
22
+ pingthings create ./my-sounds
23
+ pingthings create ./my-sounds custom-pack
24
+ `);
25
+ }
26
+
27
+ export default async function create(args) {
28
+ if (args.includes('--help') || args.includes('-h')) {
29
+ showHelp();
30
+ return;
31
+ }
32
+
33
+ const sourceDir = args[0];
34
+ if (!sourceDir) {
35
+ showHelp();
36
+ process.exit(1);
37
+ }
38
+
39
+ if (!existsSync(sourceDir)) {
40
+ console.error(`Directory not found: ${sourceDir}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ // Find audio files
45
+ const audioFiles = readdirSync(sourceDir)
46
+ .filter(f => AUDIO_EXTENSIONS.has(extname(f).toLowerCase()))
47
+ .sort();
48
+
49
+ if (audioFiles.length === 0) {
50
+ console.error(`No audio files found in ${sourceDir}`);
51
+ console.error('Supported formats: .wav, .mp3, .ogg, .flac');
52
+ process.exit(1);
53
+ }
54
+
55
+ const packName = args[1] || basename(sourceDir);
56
+ const packsDir = join(getConfigDir(), 'packs');
57
+ const packDir = join(packsDir, packName);
58
+ const soundsDir = join(packDir, 'sounds');
59
+
60
+ if (existsSync(packDir)) {
61
+ console.error(`Pack already exists: ${packDir}`);
62
+ console.error('Delete it first or choose a different name.');
63
+ process.exit(1);
64
+ }
65
+
66
+ // Create pack directory structure
67
+ mkdirSync(soundsDir, { recursive: true });
68
+
69
+ // Copy audio files
70
+ console.log(`\nCreating pack "${packName}" with ${audioFiles.length} sounds...\n`);
71
+
72
+ const soundPaths = [];
73
+ for (const file of audioFiles) {
74
+ const dest = join(soundsDir, file);
75
+ copyFileSync(join(sourceDir, file), dest);
76
+ soundPaths.push(`sounds/${file}`);
77
+ console.log(` + ${file}`);
78
+ }
79
+
80
+ // Generate manifest
81
+ const manifest = {
82
+ name: packName,
83
+ description: '',
84
+ version: '1.0.0',
85
+ license: '',
86
+ credits: '',
87
+ sounds: soundPaths,
88
+ events: {
89
+ done: [],
90
+ permission: [],
91
+ complete: [],
92
+ error: [],
93
+ blocked: [],
94
+ },
95
+ };
96
+
97
+ writeFileSync(
98
+ join(packDir, 'manifest.json'),
99
+ JSON.stringify(manifest, null, 2) + '\n',
100
+ 'utf8'
101
+ );
102
+
103
+ console.log(`\nPack created at: ${packDir}`);
104
+ console.log(`Manifest: ${join(packDir, 'manifest.json')}`);
105
+ console.log('\nNext steps:');
106
+ console.log(' 1. Edit manifest.json to add description, license, and credits');
107
+ console.log(' 2. Map sounds to events in the "events" field');
108
+ console.log(` 3. Run: pingthings use ${packName}`);
109
+ }
@@ -0,0 +1,125 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings init
9
+
10
+ Interactive setup wizard that configures Claude Code hooks
11
+ in ~/.claude/settings.json automatically.
12
+
13
+ Options:
14
+ --basic Set up basic random sounds (no prompts)
15
+ --informational Set up event-based sounds (no prompts)
16
+ --help, -h Show this help message
17
+ `);
18
+ }
19
+
20
+ const BASIC_HOOKS = {
21
+ Notification: [{
22
+ matcher: '',
23
+ hooks: [{ type: 'command', command: 'pingthings play' }],
24
+ }],
25
+ };
26
+
27
+ const INFORMATIONAL_HOOKS = {
28
+ Notification: [{
29
+ matcher: '',
30
+ hooks: [{ type: 'command', command: 'pingthings play --event permission' }],
31
+ }],
32
+ Stop: [{
33
+ matcher: '',
34
+ hooks: [{ type: 'command', command: 'pingthings play --event complete' }],
35
+ }],
36
+ PostToolUseFailure: [{
37
+ matcher: '',
38
+ hooks: [{ type: 'command', command: 'pingthings play --event error' }],
39
+ }],
40
+ StopFailure: [{
41
+ matcher: '',
42
+ hooks: [{ type: 'command', command: 'pingthings play --event blocked' }],
43
+ }],
44
+ };
45
+
46
+ function getSettingsPath() {
47
+ return join(homedir(), '.claude', 'settings.json');
48
+ }
49
+
50
+ function readSettings() {
51
+ const path = getSettingsPath();
52
+ if (!existsSync(path)) return {};
53
+ try {
54
+ return JSON.parse(readFileSync(path, 'utf8'));
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ function writeSettings(settings) {
61
+ const path = getSettingsPath();
62
+ const tmpPath = path + '.tmp';
63
+ writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
64
+ renameSync(tmpPath, path);
65
+ }
66
+
67
+ function applyHooks(mode) {
68
+ const settings = readSettings();
69
+ const hooks = mode === 'informational' ? INFORMATIONAL_HOOKS : BASIC_HOOKS;
70
+ settings.hooks = { ...settings.hooks, ...hooks };
71
+ writeSettings(settings);
72
+
73
+ console.log(`\nClaude Code hooks configured (${mode} mode).`);
74
+ console.log(`Settings written to: ${getSettingsPath()}`);
75
+
76
+ if (mode === 'informational') {
77
+ console.log('\nHook mapping:');
78
+ console.log(' Notification → permission sound');
79
+ console.log(' Stop → complete sound');
80
+ console.log(' PostToolUseFailure → error sound');
81
+ console.log(' StopFailure → blocked sound');
82
+ } else {
83
+ console.log('\nA random sound will play on every Claude Code notification.');
84
+ }
85
+
86
+ console.log('\nRestart Claude Code for hooks to take effect.');
87
+ }
88
+
89
+ export default async function init(args) {
90
+ if (args.includes('--help') || args.includes('-h')) {
91
+ showHelp();
92
+ return;
93
+ }
94
+
95
+ if (args.includes('--basic')) {
96
+ applyHooks('basic');
97
+ return;
98
+ }
99
+
100
+ if (args.includes('--informational')) {
101
+ applyHooks('informational');
102
+ return;
103
+ }
104
+
105
+ console.log('\npingthings — Claude Code hook setup\n');
106
+ console.log('How would you like sounds to work?\n');
107
+ console.log(' 1. Basic — random sound on every notification');
108
+ console.log(' 2. Informational — different sounds for different events');
109
+ console.log(' (done, permission, error, blocked)\n');
110
+
111
+ const rl = createInterface({
112
+ input: process.stdin,
113
+ output: process.stdout,
114
+ });
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();
124
+ });
125
+ }
@@ -1,8 +1,121 @@
1
- export default function install() {
2
- console.log('Remote pack installation is coming in a future version.');
3
- console.log('');
4
- console.log('For now, you can manually install packs by placing them in:');
5
- console.log(' ~/.config/pingthings/packs/<pack-name>/');
6
- console.log('');
7
- console.log('Each pack needs a manifest.json and a sounds/ directory.');
1
+ import { existsSync, mkdirSync, cpSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { getConfigDir } from '../config.js';
5
+
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings install <source>
9
+
10
+ Install a sound pack from a GitHub repository or local path.
11
+
12
+ Arguments:
13
+ source GitHub URL, GitHub shorthand (user/repo), or local path
14
+
15
+ The pack will be installed to ~/.config/pingthings/packs/
16
+
17
+ Examples:
18
+ pingthings install user/repo
19
+ pingthings install https://github.com/user/pingthings-pack-name
20
+ pingthings install ./path/to/local/pack
21
+ `);
22
+ }
23
+
24
+ function installFromGit(source) {
25
+ const packsDir = join(getConfigDir(), 'packs');
26
+ mkdirSync(packsDir, { recursive: true });
27
+
28
+ // Convert shorthand to full URL
29
+ let url = source;
30
+ if (!source.startsWith('http') && !source.startsWith('git@') && source.includes('/')) {
31
+ url = `https://github.com/${source}.git`;
32
+ }
33
+
34
+ const repoName = basename(url, '.git');
35
+ const destDir = join(packsDir, repoName);
36
+
37
+ if (existsSync(destDir)) {
38
+ console.error(`Pack already exists: ${repoName}`);
39
+ console.error(`Delete ${destDir} first to reinstall.`);
40
+ process.exit(1);
41
+ }
42
+
43
+ console.log(`Cloning ${url}...`);
44
+
45
+ try {
46
+ execFileSync('git', ['clone', '--depth', '1', url, destDir], {
47
+ stdio: 'inherit',
48
+ });
49
+ } catch {
50
+ console.error('Failed to clone repository. Check the URL and try again.');
51
+ process.exit(1);
52
+ }
53
+
54
+ // Check for manifest
55
+ if (!existsSync(join(destDir, 'manifest.json'))) {
56
+ console.log('\nNote: No manifest.json found. The pack may need one to work properly.');
57
+ console.log('Run "pingthings create" to generate a manifest from audio files.');
58
+ }
59
+
60
+ console.log(`\nInstalled: ${repoName}`);
61
+ console.log(`Location: ${destDir}`);
62
+ console.log(`\nTo use: pingthings use ${repoName}`);
63
+ }
64
+
65
+ function installFromLocal(source) {
66
+ if (!existsSync(source)) {
67
+ console.error(`Path not found: ${source}`);
68
+ process.exit(1);
69
+ }
70
+
71
+ if (!existsSync(join(source, 'manifest.json'))) {
72
+ console.error('No manifest.json found in the source directory.');
73
+ console.error('Run "pingthings create <dir>" to create a pack from audio files.');
74
+ process.exit(1);
75
+ }
76
+
77
+ const packName = basename(source);
78
+ if (!packName) {
79
+ console.error('Could not determine pack name from path.');
80
+ process.exit(1);
81
+ }
82
+ const packsDir = join(getConfigDir(), 'packs');
83
+ const destDir = join(packsDir, packName);
84
+
85
+ if (existsSync(destDir)) {
86
+ console.error(`Pack already exists: ${packName}`);
87
+ console.error(`Delete ${destDir} first to reinstall.`);
88
+ process.exit(1);
89
+ }
90
+
91
+ mkdirSync(packsDir, { recursive: true });
92
+
93
+ try {
94
+ cpSync(source, destDir, { recursive: true });
95
+ } catch {
96
+ console.error('Failed to copy pack.');
97
+ process.exit(1);
98
+ }
99
+
100
+ console.log(`Installed: ${packName}`);
101
+ console.log(`Location: ${destDir}`);
102
+ console.log(`\nTo use: pingthings use ${packName}`);
103
+ }
104
+
105
+ export default function install(args) {
106
+ const source = args[0];
107
+
108
+ if (!source || source === '--help' || source === '-h') {
109
+ showHelp();
110
+ if (!source) process.exit(1);
111
+ return;
112
+ }
113
+
114
+ // Determine if it's a git URL, shorthand, or local path
115
+ if (source.startsWith('http') || source.startsWith('git@') ||
116
+ (source.includes('/') && !source.startsWith('.') && !source.startsWith('/'))) {
117
+ installFromGit(source);
118
+ } else {
119
+ installFromLocal(source);
120
+ }
8
121
  }
package/src/cli/list.js CHANGED
@@ -1,7 +1,21 @@
1
1
  import { readConfig } from '../config.js';
2
2
  import { listPacks } from '../packs.js';
3
3
 
4
- export default function list() {
4
+ function showHelp() {
5
+ console.log(`
6
+ Usage: pingthings list
7
+
8
+ Show all available sound packs with their sound count and source.
9
+ The active pack is marked with *.
10
+ `);
11
+ }
12
+
13
+ export default function list(args) {
14
+ if (args?.includes('--help') || args?.includes('-h')) {
15
+ showHelp();
16
+ return;
17
+ }
18
+
5
19
  const config = readConfig();
6
20
  const packs = listPacks();
7
21
 
package/src/cli/play.js CHANGED
@@ -78,10 +78,15 @@ export default function play(args) {
78
78
 
79
79
  // If --event flag is provided, use event mapping
80
80
  if (parsed.event) {
81
- const eventSounds = getEventSounds(packName, parsed.event);
81
+ // Pack mixing: check if there's a per-event pack override
82
+ const eventPackName = config.eventPacks?.[parsed.event] || packName;
83
+ const eventPack = resolvePack(eventPackName);
84
+ const resolvedPack = eventPack ? eventPackName : packName;
85
+
86
+ const eventSounds = getEventSounds(resolvedPack, parsed.event);
82
87
  if (eventSounds.length === 0) {
83
88
  // Fall back to random if pack has no mapping for this event
84
- const allSounds = getPackSounds(packName);
89
+ const allSounds = getPackSounds(resolvedPack);
85
90
  soundFile = pickRandom(allSounds);
86
91
  } else {
87
92
  soundFile = pickRandom(eventSounds);
@@ -123,5 +128,10 @@ export default function play(args) {
123
128
  soundFile = pickRandom(sounds);
124
129
  }
125
130
 
126
- playSound(soundFile);
131
+ if (!soundFile) {
132
+ console.error(`No sounds available in pack: ${packName}`);
133
+ process.exit(1);
134
+ }
135
+
136
+ playSound(soundFile, config.volume);
127
137
  }
@@ -1,14 +1,26 @@
1
+ import { readConfig } from '../config.js';
1
2
  import { getPackSounds, pickRandom, resolvePack } from '../packs.js';
2
3
  import { playSound } from '../player.js';
3
4
  import { basename } from 'node:path';
4
5
 
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings preview <pack>
9
+
10
+ Play a random sound from a pack to hear what it sounds like.
11
+
12
+ Arguments:
13
+ pack Name of the pack to preview
14
+ `);
15
+ }
16
+
5
17
  export default function preview(args) {
6
18
  const packName = args[0];
7
19
 
8
- if (!packName) {
9
- console.error('Usage: pingthings preview <pack>');
10
- console.error('Run "pingthings list" to see available packs.');
11
- process.exit(1);
20
+ if (!packName || packName === '--help' || packName === '-h') {
21
+ showHelp();
22
+ if (!packName) process.exit(1);
23
+ return;
12
24
  }
13
25
 
14
26
  const pack = resolvePack(packName);
@@ -24,7 +36,8 @@ export default function preview(args) {
24
36
  process.exit(1);
25
37
  }
26
38
 
39
+ const config = readConfig();
27
40
  const sound = pickRandom(sounds);
28
41
  console.log(`Playing: ${basename(sound)} from "${packName}"`);
29
- playSound(sound);
42
+ playSound(sound, config.volume);
30
43
  }
@@ -0,0 +1,81 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { readConfig, writeConfig } from '../config.js';
3
+ import { listPacks, getPackSounds } from '../packs.js';
4
+ import { playSound } from '../player.js';
5
+ import { basename } from 'node:path';
6
+
7
+ function showHelp() {
8
+ console.log(`
9
+ Usage: pingthings select
10
+
11
+ Interactive menu for choosing your active sound pack.
12
+ Displays a numbered list — type a number to preview and select.
13
+ `);
14
+ }
15
+
16
+ export default async function select(args) {
17
+ if (args.includes('--help') || args.includes('-h')) {
18
+ showHelp();
19
+ return;
20
+ }
21
+
22
+ const config = readConfig();
23
+ const packs = listPacks();
24
+
25
+ if (packs.length === 0) {
26
+ console.error('No sound packs found.');
27
+ process.exit(1);
28
+ }
29
+
30
+ console.log('\nChoose a sound pack:\n');
31
+
32
+ for (let i = 0; i < packs.length; i++) {
33
+ const active = packs[i].name === config.activePack ? ' *' : ' ';
34
+ console.log(`${active} ${i + 1}. ${packs[i].name} (${packs[i].soundCount} sounds)`);
35
+ if (packs[i].description) {
36
+ console.log(` ${packs[i].description}`);
37
+ }
38
+ }
39
+
40
+ console.log('\n * = current pack');
41
+ console.log(' Enter a number to preview & select, or q to quit.\n');
42
+
43
+ const rl = createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ });
47
+
48
+ const ask = () => {
49
+ rl.question('> ', (answer) => {
50
+ const trimmed = answer.trim().toLowerCase();
51
+
52
+ if (trimmed === 'q' || trimmed === 'quit' || trimmed === '') {
53
+ 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();
81
+ }
@@ -0,0 +1,51 @@
1
+ import { readConfig, VALID_EVENTS } from '../config.js';
2
+ import { getEventSounds, getPackSounds, pickRandom, resolvePack } from '../packs.js';
3
+ import { playSoundSync } from '../player.js';
4
+ import { basename } from 'node:path';
5
+
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings test-events [pack]
9
+
10
+ Play one sound for each event type from a pack so you can hear
11
+ what each event sounds like. Defaults to the active pack.
12
+
13
+ Arguments:
14
+ pack Pack to test (optional, defaults to active pack)
15
+
16
+ Events played: done, permission, complete, error, blocked
17
+ `);
18
+ }
19
+
20
+ export default async function testEvents(args) {
21
+ if (args.includes('--help') || args.includes('-h')) {
22
+ showHelp();
23
+ return;
24
+ }
25
+
26
+ const config = readConfig();
27
+ const packName = args[0] || config.activePack;
28
+ const pack = resolvePack(packName);
29
+
30
+ if (!pack) {
31
+ console.error(`Pack not found: ${packName}`);
32
+ console.error('Run "pingthings list" to see available packs.');
33
+ process.exit(1);
34
+ }
35
+
36
+ console.log(`\nTesting events for: ${packName}\n`);
37
+
38
+ for (const event of VALID_EVENTS) {
39
+ const sounds = getEventSounds(packName, event);
40
+ if (sounds.length === 0) {
41
+ console.log(` ${event.padEnd(12)} (no sounds mapped)`);
42
+ continue;
43
+ }
44
+
45
+ const sound = pickRandom(sounds);
46
+ console.log(` ${event.padEnd(12)} ${basename(sound)}`);
47
+ playSoundSync(sound, config.volume);
48
+ }
49
+
50
+ console.log('\nDone.\n');
51
+ }
@@ -0,0 +1,132 @@
1
+ import { readConfig, writeConfig } from '../config.js';
2
+ import { resolvePack } from '../packs.js';
3
+
4
+ const THEMES = {
5
+ 'retro': {
6
+ description: 'Classic retro gaming — Freedoom weapons and 7kaa soldiers',
7
+ activePack: '7kaa-soldiers',
8
+ eventPacks: {
9
+ done: '7kaa-soldiers',
10
+ permission: '7kaa-soldiers',
11
+ complete: 'freedoom-arsenal',
12
+ error: 'freedoom-arsenal',
13
+ blocked: 'freedoom-arsenal',
14
+ },
15
+ },
16
+ 'sci-fi': {
17
+ description: 'Sci-fi command center — Warzone 2100 commander voice',
18
+ activePack: 'warzone2100-command',
19
+ eventPacks: {
20
+ done: 'warzone2100-command',
21
+ permission: 'warzone2100-command',
22
+ complete: 'warzone2100-command',
23
+ error: 'warzone2100-command',
24
+ blocked: 'warzone2100-command',
25
+ },
26
+ },
27
+ 'arena': {
28
+ description: 'Arena announcer with FPS weapons for errors',
29
+ activePack: 'openarena-announcer',
30
+ eventPacks: {
31
+ done: 'openarena-announcer',
32
+ permission: 'openarena-announcer',
33
+ complete: 'openarena-announcer',
34
+ error: 'freedoom-arsenal',
35
+ blocked: 'freedoom-arsenal',
36
+ },
37
+ },
38
+ 'fantasy': {
39
+ description: 'Medieval fantasy — Wesnoth combat and 0 A.D. civilizations',
40
+ activePack: 'wesnoth-combat',
41
+ eventPacks: {
42
+ done: 'wesnoth-combat',
43
+ permission: '0ad-civilizations',
44
+ complete: 'wesnoth-combat',
45
+ error: 'wesnoth-combat',
46
+ blocked: '0ad-civilizations',
47
+ },
48
+ },
49
+ 'ancient': {
50
+ description: 'Ancient world — 7kaa soldiers and 0 A.D. civilizations',
51
+ activePack: '7kaa-soldiers',
52
+ eventPacks: {
53
+ done: '7kaa-soldiers',
54
+ permission: '0ad-civilizations',
55
+ complete: '0ad-civilizations',
56
+ error: '7kaa-soldiers',
57
+ blocked: '0ad-civilizations',
58
+ },
59
+ },
60
+ 'chaos': {
61
+ description: 'Random pack for every event — maximum variety',
62
+ activePack: '7kaa-soldiers',
63
+ eventPacks: {
64
+ done: '7kaa-soldiers',
65
+ permission: 'openarena-announcer',
66
+ complete: 'warzone2100-command',
67
+ error: 'freedoom-arsenal',
68
+ blocked: 'wesnoth-combat',
69
+ },
70
+ },
71
+ };
72
+
73
+ function showHelp() {
74
+ console.log(`
75
+ Usage: pingthings theme [name]
76
+
77
+ Apply a sound theme that maps events to packs.
78
+
79
+ With no arguments, lists available themes.
80
+
81
+ Available themes:`);
82
+
83
+ for (const [name, theme] of Object.entries(THEMES)) {
84
+ console.log(` ${name.padEnd(12)} ${theme.description}`);
85
+ }
86
+
87
+ console.log(`
88
+ Examples:
89
+ pingthings theme List themes
90
+ pingthings theme sci-fi Apply the sci-fi theme
91
+ pingthings theme reset Reset to defaults
92
+ `);
93
+ }
94
+
95
+ export default function theme(args) {
96
+ const name = args[0];
97
+
98
+ if (!name || name === '--help' || name === '-h') {
99
+ showHelp();
100
+ return;
101
+ }
102
+
103
+ if (name === 'reset') {
104
+ const config = readConfig();
105
+ config.eventPacks = {};
106
+ config.mode = 'random';
107
+ writeConfig(config);
108
+ console.log('Theme reset. Using default random mode.');
109
+ return;
110
+ }
111
+
112
+ const selected = THEMES[name];
113
+ if (!selected) {
114
+ console.error(`Unknown theme: ${name}`);
115
+ console.error(`Available themes: ${Object.keys(THEMES).join(', ')}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ const config = readConfig();
120
+ config.activePack = selected.activePack;
121
+ config.mode = 'informational';
122
+ config.eventPacks = { ...selected.eventPacks };
123
+ writeConfig(config);
124
+
125
+ console.log(`\nTheme applied: ${name}`);
126
+ console.log(`${selected.description}\n`);
127
+ console.log('Event mapping:');
128
+ for (const [event, pack] of Object.entries(selected.eventPacks)) {
129
+ console.log(` ${event.padEnd(12)} → ${pack}`);
130
+ }
131
+ console.log(`\nMode set to: informational`);
132
+ }
package/src/cli/use.js CHANGED
@@ -1,13 +1,27 @@
1
1
  import { readConfig, writeConfig } from '../config.js';
2
2
  import { resolvePack } from '../packs.js';
3
3
 
4
+ function showHelp() {
5
+ console.log(`
6
+ Usage: pingthings use <pack>
7
+
8
+ Set the active sound pack.
9
+
10
+ Arguments:
11
+ pack Name of the pack to activate
12
+
13
+ Run "pingthings list" to see available packs.
14
+ Run "pingthings select" for an interactive picker.
15
+ `);
16
+ }
17
+
4
18
  export default function use(args) {
5
19
  const packName = args[0];
6
20
 
7
- if (!packName) {
8
- console.error('Usage: pingthings use <pack>');
9
- console.error('Run "pingthings list" to see available packs.');
10
- process.exit(1);
21
+ if (!packName || packName === '--help' || packName === '-h') {
22
+ showHelp();
23
+ if (!packName) process.exit(1);
24
+ return;
11
25
  }
12
26
 
13
27
  const pack = resolvePack(packName);
package/src/config.js CHANGED
@@ -6,6 +6,8 @@ const DEFAULTS = {
6
6
  activePack: '7kaa-soldiers',
7
7
  mode: 'random',
8
8
  specificSound: null,
9
+ volume: 100,
10
+ eventPacks: {},
9
11
  };
10
12
 
11
13
  export function getConfigDir() {
package/src/packs.js CHANGED
@@ -48,6 +48,7 @@ function scanDirectory(dir, isBuiltIn) {
48
48
 
49
49
  return {
50
50
  name: manifest?.name || d.name,
51
+ version: manifest?.version || '0.0.0',
51
52
  description: manifest?.description || '',
52
53
  license: manifest?.license || 'Unknown',
53
54
  credits: manifest?.credits || '',
package/src/player.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { platform } from 'node:os';
4
4
 
@@ -7,33 +7,82 @@ function getPlayerCommand() {
7
7
  case 'darwin':
8
8
  return 'afplay';
9
9
  case 'linux': {
10
- // Prefer PulseAudio/PipeWire, fall back to ALSA
11
10
  try {
12
- const result = spawn('which', ['paplay'], { stdio: 'pipe' });
11
+ execFileSync('which', ['paplay'], { stdio: 'pipe' });
13
12
  return 'paplay';
14
13
  } catch {
15
14
  return 'aplay';
16
15
  }
17
16
  }
17
+ case 'win32':
18
+ return 'powershell';
18
19
  default:
19
20
  return null;
20
21
  }
21
22
  }
22
23
 
23
- export function playSound(filePath) {
24
+ function buildArgs(cmd, filePath, volume) {
25
+ const vol = Math.max(0, Math.min(100, volume ?? 100));
26
+
27
+ switch (cmd) {
28
+ case 'afplay': {
29
+ // afplay volume: 0.0 to 1.0
30
+ const afplayVol = (vol / 100).toFixed(2);
31
+ return ['-v', afplayVol, filePath];
32
+ }
33
+ case 'paplay': {
34
+ // paplay volume: 0 to 65536 (100% = 65536)
35
+ const paplayVol = Math.round((vol / 100) * 65536).toString();
36
+ return ['--volume', paplayVol, filePath];
37
+ }
38
+ case 'aplay':
39
+ // aplay doesn't support volume natively
40
+ return [filePath];
41
+ case 'powershell': {
42
+ // PowerShell SoundPlayer doesn't support volume, but it plays the file
43
+ const script = `(New-Object System.Media.SoundPlayer '${filePath.replace(/'/g, "''")}').PlaySync()`;
44
+ return ['-NoProfile', '-Command', script];
45
+ }
46
+ default:
47
+ return [filePath];
48
+ }
49
+ }
50
+
51
+ export function playSound(filePath, volume) {
24
52
  if (!existsSync(filePath)) {
25
53
  throw new Error(`Sound file not found: ${filePath}`);
26
54
  }
27
55
 
28
56
  const cmd = getPlayerCommand();
29
57
  if (!cmd) {
30
- throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux.`);
58
+ throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux, Windows.`);
31
59
  }
32
60
 
33
- const child = spawn(cmd, [filePath], {
61
+ const args = buildArgs(cmd, filePath, volume);
62
+
63
+ const child = spawn(cmd, args, {
34
64
  detached: true,
35
65
  stdio: 'ignore',
36
66
  });
37
67
 
38
68
  child.unref();
39
69
  }
70
+
71
+ export function playSoundSync(filePath, volume) {
72
+ if (!existsSync(filePath)) {
73
+ throw new Error(`Sound file not found: ${filePath}`);
74
+ }
75
+
76
+ const cmd = getPlayerCommand();
77
+ if (!cmd) {
78
+ throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux, Windows.`);
79
+ }
80
+
81
+ const args = buildArgs(cmd, filePath, volume);
82
+
83
+ try {
84
+ execFileSync(cmd, args, { stdio: 'ignore', timeout: 10000 });
85
+ } catch {
86
+ // Timeout or error — don't crash
87
+ }
88
+ }