claude-code-sounds 1.1.0 → 1.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ryparker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Plays sound effects when sessions start, prompts are submitted, responses finish, errors occur, and more.
10
10
 
11
- Ships with a **WC3 Orc Peon** theme. Bring your own sounds or create new themes.
11
+ Ships with a **WC3 Orc Peon** theme and a **Zelda: Ocarina of Time** theme. Bring your own sounds or create new themes.
12
12
 
13
13
  *"Something need doing?"*
14
14
 
@@ -55,7 +55,7 @@ npx claude-code-sounds --help # Show help
55
55
 
56
56
  ## WC3 Orc Peon Theme
57
57
 
58
- 55 sounds from Warcraft 3 Orc units mapped across 11 Claude Code lifecycle events.
58
+ 57 sounds from Warcraft 3 Orc units mapped across 11 Claude Code lifecycle events.
59
59
 
60
60
  > After installing, preview all sounds with `./preview.sh` or a specific category with `./preview.sh start`
61
61
 
@@ -88,11 +88,12 @@ npx claude-code-sounds --help # Show help
88
88
  </details>
89
89
 
90
90
  <details>
91
- <summary><b>stop</b> — Claude finished responding (8 sounds)</summary>
91
+ <summary><b>stop</b> — Claude finished responding (9 sounds)</summary>
92
92
 
93
93
  | Sound | Quote | Unit |
94
94
  |---|---|---|
95
- | `zug-zug.wav` | *"Zug zug"* | Peon |
95
+ | `zug-zug.wav` | *"Zug zug"* | Grunt |
96
+ | `get-em.wav` | *"Get 'em!"* | Peon |
96
97
  | `ok.wav` | *"OK"* | Peon |
97
98
  | `i-can-do-that.wav` | *"I can do that"* | Peon |
98
99
  | `be-happy-to.wav` | *"Be happy to"* | Peon |
@@ -117,12 +118,13 @@ npx claude-code-sounds --help # Show help
117
118
  </details>
118
119
 
119
120
  <details>
120
- <summary><b>subagent</b> — Spawning a subagent (6 sounds)</summary>
121
+ <summary><b>subagent</b> — Spawning a subagent (7 sounds)</summary>
121
122
 
122
123
  | Sound | Quote | Unit |
123
124
  |---|---|---|
124
125
  | `work-work.wav` | *"Work, work"* | Peon |
125
- | `zug-zug.wav` | *"Zug zug"* | Peon |
126
+ | `zug-zug.wav` | *"Zug zug"* | Grunt |
127
+ | `get-em.wav` | *"Get 'em!"* | Peon |
126
128
  | `ill-try.wav` | *"I'll try"* | Peon |
127
129
  | `why-not.wav` | *"Why not?"* | Peon |
128
130
  | `for-the-horde.wav` | *"For the Horde!"* | Grunt |
@@ -202,6 +204,147 @@ npx claude-code-sounds --help # Show help
202
204
 
203
205
  </details>
204
206
 
207
+ ## Zelda: Ocarina of Time Theme
208
+
209
+ 47 sounds from Navi, Link, Ganondorf, and iconic OOT jingles mapped across 11 Claude Code lifecycle events.
210
+
211
+ *"Hey! Listen!"*
212
+
213
+ <details open>
214
+ <summary><b>start</b> — Session starting, Navi greets you (5 sounds)</summary>
215
+
216
+ | Sound | Description | Source |
217
+ |---|---|---|
218
+ | `hey.wav` | *"Hey!"* | Navi |
219
+ | `hello.wav` | *"Hello!"* | Navi |
220
+ | `listen.wav` | *"Listen!"* | Navi |
221
+ | `great-fairy-laugh.wav` | *(magical laugh)* | Great Fairy |
222
+ | `open-chest.wav` | *(opening big chest)* | SFX |
223
+
224
+ </details>
225
+
226
+ <details>
227
+ <summary><b>prompt</b> — User submitted a prompt, adventure continues (5 sounds)</summary>
228
+
229
+ | Sound | Description | Source |
230
+ |---|---|---|
231
+ | `hey.wav` | *"Hey!"* | Navi |
232
+ | `look.wav` | *"Look!"* | Navi |
233
+ | `menu-select.wav` | *(menu select)* | SFX |
234
+ | `dialogue-next.wav` | *(continue dialogue)* | SFX |
235
+ | `get-heart.wav` | *(get heart)* | SFX |
236
+
237
+ </details>
238
+
239
+ <details>
240
+ <summary><b>stop</b> — Claude finished responding (5 sounds)</summary>
241
+
242
+ | Sound | Description | Source |
243
+ |---|---|---|
244
+ | `secret-discovered.wav` | *(secret jingle)* | SFX |
245
+ | `get-item.wav` | *(get small item)* | SFX |
246
+ | `get-rupee.wav` | *(get rupee)* | SFX |
247
+ | `open-small-chest.wav` | *(open small chest)* | SFX |
248
+ | `song-correct.wav` | *(song played correctly)* | SFX |
249
+
250
+ </details>
251
+
252
+ <details>
253
+ <summary><b>permission</b> — Permission prompt, waiting for approval (5 sounds)</summary>
254
+
255
+ | Sound | Description | Source |
256
+ |---|---|---|
257
+ | `watch-out.wav` | *"Watch out!"* | Navi |
258
+ | `hey.wav` | *"Hey!"* | Navi |
259
+ | `listen.wav` | *"Listen!"* | Navi |
260
+ | `pause-menu.wav` | *(pause menu open)* | SFX |
261
+ | `z-target.wav` | *(Z-target enemy)* | SFX |
262
+
263
+ </details>
264
+
265
+ <details>
266
+ <summary><b>subagent</b> — Spawning a subagent (5 sounds)</summary>
267
+
268
+ | Sound | Description | Source |
269
+ |---|---|---|
270
+ | `sword-draw.wav` | *(draw sword)* | SFX |
271
+ | `link-attack.wav` | *(attack shout)* | Adult Link |
272
+ | `link-strong-attack.wav` | *(strong attack)* | Adult Link |
273
+ | `hey.wav` | *"Hey!"* | Navi |
274
+ | `spin-attack.wav` | *(spin attack)* | SFX |
275
+
276
+ </details>
277
+
278
+ <details>
279
+ <summary><b>idle</b> — Waiting for user input (5 sounds)</summary>
280
+
281
+ | Sound | Description | Source |
282
+ |---|---|---|
283
+ | `hey.wav` | *"Hey!"* | Navi |
284
+ | `listen.wav` | *"Listen!"* | Navi |
285
+ | `watch-out.wav` | *"Watch out!"* | Navi |
286
+ | `low-health.wav` | *(low health beep)* | SFX |
287
+ | `snore.wav` | *(Talon snoring)* | Talon |
288
+
289
+ </details>
290
+
291
+ <details>
292
+ <summary><b>error</b> — Tool call failed (4 sounds)</summary>
293
+
294
+ | Sound | Description | Source |
295
+ |---|---|---|
296
+ | `error.wav` | *(error sound)* | SFX |
297
+ | `song-error.wav` | *(wrong note)* | SFX |
298
+ | `link-hurt.wav` | *(Link hurt)* | Adult Link |
299
+ | `ganondorf-laugh.wav` | *(Ganondorf laughing)* | Ganondorf |
300
+
301
+ </details>
302
+
303
+ <details>
304
+ <summary><b>end</b> — Session ending (3 sounds)</summary>
305
+
306
+ | Sound | Description | Source |
307
+ |---|---|---|
308
+ | `item-fanfare.wav` | *(item fanfare)* | SFX |
309
+ | `secret-discovered.wav` | *(secret jingle)* | SFX |
310
+ | `dialogue-done.wav` | *(dialogue finished)* | SFX |
311
+
312
+ </details>
313
+
314
+ <details>
315
+ <summary><b>task-completed</b> — Task marked done (2 sounds)</summary>
316
+
317
+ | Sound | Description | Source |
318
+ |---|---|---|
319
+ | `item-fanfare.wav` | *(item fanfare)* | SFX |
320
+ | `secret-discovered.wav` | *(secret jingle)* | SFX |
321
+
322
+ </details>
323
+
324
+ <details>
325
+ <summary><b>compact</b> — Context compaction, health running low (4 sounds)</summary>
326
+
327
+ | Sound | Description | Source |
328
+ |---|---|---|
329
+ | `low-health.wav` | *(low health beep)* | SFX |
330
+ | `pause-close.wav` | *(pause menu close)* | SFX |
331
+ | `watch-out.wav` | *"Watch out!"* | Navi |
332
+ | `error.wav` | *(error sound)* | SFX |
333
+
334
+ </details>
335
+
336
+ <details>
337
+ <summary><b>teammate-idle</b> — Teammate went idle (4 sounds)</summary>
338
+
339
+ | Sound | Description | Source |
340
+ |---|---|---|
341
+ | `hey.wav` | *"Hey!"* | Navi |
342
+ | `listen.wav` | *"Listen!"* | Navi |
343
+ | `watch-out.wav` | *"Watch out!"* | Navi |
344
+ | `low-health.wav` | *(low health beep)* | SFX |
345
+
346
+ </details>
347
+
205
348
  ## Hook Events
206
349
 
207
350
  | Event | Hook | When |
@@ -230,6 +373,7 @@ Defines metadata and maps source files to hook categories:
230
373
  {
231
374
  "name": "My Theme",
232
375
  "description": "A short description",
376
+ "srcBase": "MyTheme",
233
377
  "sounds": {
234
378
  "start": {
235
379
  "description": "Session starting",
@@ -241,13 +385,16 @@ Defines metadata and maps source files to hook categories:
241
385
  }
242
386
  ```
243
387
 
388
+ - **`srcBase`** — subdirectory under the temp dir where downloaded files live (e.g., `"Orc"`, `"OOT"`)
389
+ - **`src`** — path relative to `$2/<srcBase>/` where the file is found after download
390
+
244
391
  ### `download.sh`
245
392
 
246
393
  Downloads the sound files. Receives two arguments:
247
394
  - `$1` — target sounds directory (`~/.claude/sounds`)
248
395
  - `$2` — temp directory for downloads
249
396
 
250
- The script should download and extract files so they're accessible at `$2/Orc/<src path>` (matching the `src` values in `theme.json`).
397
+ The script should download and extract files so they're accessible at `$2/<srcBase>/<src path>` (matching the `srcBase` and `src` values in `theme.json`).
251
398
 
252
399
  ## How It Works
253
400
 
@@ -279,4 +426,4 @@ This removes all sound files, the hook script, and the hooks config from `settin
279
426
 
280
427
  ## Disclaimer
281
428
 
282
- Sound files are downloaded from third-party sources at install time and are not included in this repository. All game audio is property of Blizzard Entertainment.
429
+ Sound files are downloaded from third-party sources at install time and are not included in this repository. Warcraft audio is property of Blizzard Entertainment. Zelda audio is property of Nintendo.
package/bin/cli.js CHANGED
@@ -14,6 +14,7 @@ const SOUNDS_DIR = path.join(CLAUDE_DIR, "sounds");
14
14
  const HOOKS_DIR = path.join(CLAUDE_DIR, "hooks");
15
15
  const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
16
16
  const THEMES_DIR = path.join(PKG_DIR, "themes");
17
+ const INSTALLED_PATH = path.join(SOUNDS_DIR, ".installed.json");
17
18
 
18
19
  // ─── Helpers ─────────────────────────────────────────────────────────────────
19
20
 
@@ -66,6 +67,53 @@ function hasCommand(name) {
66
67
  }
67
68
  }
68
69
 
70
+ function readInstalled() {
71
+ if (fs.existsSync(INSTALLED_PATH)) {
72
+ return JSON.parse(fs.readFileSync(INSTALLED_PATH, "utf-8"));
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function writeInstalled(themeName) {
78
+ mkdirp(SOUNDS_DIR);
79
+ fs.writeFileSync(INSTALLED_PATH, JSON.stringify({ theme: themeName }, null, 2) + "\n");
80
+ }
81
+
82
+ /**
83
+ * Check if sounds are already installed.
84
+ * Returns { theme, themeDisplay, totalEnabled, totalAvailable, categories } or null.
85
+ */
86
+ function getExistingInstall() {
87
+ const installed = readInstalled();
88
+ if (!installed) return null;
89
+
90
+ const themeJsonPath = path.join(THEMES_DIR, installed.theme, "theme.json");
91
+ if (!fs.existsSync(themeJsonPath)) return null;
92
+
93
+ const theme = JSON.parse(fs.readFileSync(themeJsonPath, "utf-8"));
94
+ let totalEnabled = 0;
95
+ const totalAvailable = Object.values(theme.sounds).reduce((sum, c) => sum + c.files.length, 0);
96
+
97
+ for (const cat of Object.keys(theme.sounds)) {
98
+ const catDir = path.join(SOUNDS_DIR, cat);
99
+ try {
100
+ for (const f of fs.readdirSync(catDir)) {
101
+ if (f.endsWith(".wav") || f.endsWith(".mp3")) totalEnabled++;
102
+ }
103
+ } catch {}
104
+ }
105
+
106
+ if (totalEnabled === 0) return null;
107
+
108
+ return {
109
+ theme: installed.theme,
110
+ themeDisplay: theme.name,
111
+ themeDescription: theme.description,
112
+ totalEnabled,
113
+ totalAvailable,
114
+ };
115
+ }
116
+
69
117
  // ─── ANSI helpers ────────────────────────────────────────────────────────────
70
118
 
71
119
  const CSI = "\x1b[";
@@ -191,29 +239,61 @@ function select(title, options) {
191
239
 
192
240
  /**
193
241
  * Multi-select checklist with toggle, preview, and confirm.
194
- * Returns array of selected indices.
242
+ * Returns array of selected indices, or null if back was pressed.
195
243
  */
196
- function multiSelect(title, items, defaults, previewDir) {
244
+ function multiSelect(title, items, defaults, previewDir, { allowBack = false } = {}) {
197
245
  return new Promise((resolve) => {
198
246
  let cursor = 0;
247
+ let scrollTop = 0;
199
248
  const checked = items.map((_, i) => defaults.includes(i));
200
- const lineCount = items.length + 3; // title + blank + items + hint
249
+
250
+ // Calculate scrolling dimensions
251
+ const termRows = process.stdout.rows || 24;
252
+ const maxItemRows = Math.max(5, termRows - 5); // 5 = title + blank + hint + 2 buffer
253
+ const needsScroll = items.length > maxItemRows;
254
+ // When scrolling, reserve 2 rows for ▲/▼ indicators (always present for stable line count)
255
+ const visibleCount = needsScroll ? maxItemRows - 2 : items.length;
256
+ const lineCount = needsScroll ? maxItemRows + 3 : items.length + 3;
257
+
258
+ function adjustScroll() {
259
+ if (!needsScroll) return;
260
+ if (cursor < scrollTop) scrollTop = cursor;
261
+ if (cursor >= scrollTop + visibleCount) scrollTop = cursor - visibleCount + 1;
262
+ }
201
263
 
202
264
  function render(initial) {
203
265
  if (!initial) moveCursorUp(lineCount);
204
266
  print(` ${title}\n`);
205
- for (let i = 0; i < items.length; i++) {
206
- const pointer = i === cursor ? `${CYAN} ❯ ` : " ";
207
- const box = checked[i] ? `${GREEN}[✓]${RESET}` : `${DIM}[ ]${RESET}`;
208
- const label = items[i].label;
209
- const desc = items[i].description ? ` ${DIM}${items[i].description}${RESET}` : "";
210
- print(`${pointer}${RESET}${box} ${label}${desc}`);
267
+
268
+ if (needsScroll) {
269
+ const above = scrollTop;
270
+ const below = items.length - scrollTop - visibleCount;
271
+ print(above > 0 ? `${DIM}${above} more${RESET}` : "");
272
+ for (let i = scrollTop; i < scrollTop + visibleCount; i++) {
273
+ const pointer = i === cursor ? `${CYAN} ❯ ` : " ";
274
+ const box = checked[i] ? `${GREEN}[✓]${RESET}` : `${DIM}[ ]${RESET}`;
275
+ const label = items[i].label;
276
+ const desc = items[i].description ? ` ${DIM}${items[i].description}${RESET}` : "";
277
+ print(`${pointer}${RESET}${box} ${label}${desc}`);
278
+ }
279
+ print(below > 0 ? `${DIM} ▼ ${below} more${RESET}` : "");
280
+ } else {
281
+ for (let i = 0; i < items.length; i++) {
282
+ const pointer = i === cursor ? `${CYAN} ❯ ` : " ";
283
+ const box = checked[i] ? `${GREEN}[✓]${RESET}` : `${DIM}[ ]${RESET}`;
284
+ const label = items[i].label;
285
+ const desc = items[i].description ? ` ${DIM}${items[i].description}${RESET}` : "";
286
+ print(`${pointer}${RESET}${box} ${label}${desc}`);
287
+ }
211
288
  }
289
+
212
290
  const previewHint = previewDir ? " · p preview" : "";
213
- print(`${DIM} ↑↓ navigate · space toggle${previewHint} · enter confirm${RESET}`);
291
+ const backHint = allowBack ? "← back · " : "";
292
+ print(`${DIM} ${backHint}↑↓ navigate · space toggle · a all${previewHint} · →/enter confirm${RESET}`);
214
293
  }
215
294
 
216
295
  process.stdout.write(HIDE_CURSOR);
296
+ adjustScroll();
217
297
  render(true);
218
298
 
219
299
  process.stdin.setRawMode(true);
@@ -230,14 +310,29 @@ function multiSelect(title, items, defaults, previewDir) {
230
310
  return;
231
311
  }
232
312
 
313
+ // Left arrow — go back
314
+ if (allowBack && key === "\x1b[D") {
315
+ process.stdin.setRawMode(false);
316
+ process.stdin.pause();
317
+ process.stdin.removeListener("data", onKey);
318
+ killPreview();
319
+ moveCursorUp(lineCount);
320
+ clearLines(lineCount);
321
+ process.stdout.write(SHOW_CURSOR);
322
+ resolve(null);
323
+ return;
324
+ }
325
+
233
326
  if (key === "\x1b[A" || key === "k") {
234
327
  cursor = (cursor - 1 + items.length) % items.length;
328
+ adjustScroll();
235
329
  render(false);
236
330
  return;
237
331
  }
238
332
 
239
333
  if (key === "\x1b[B" || key === "j") {
240
334
  cursor = (cursor + 1) % items.length;
335
+ adjustScroll();
241
336
  render(false);
242
337
  return;
243
338
  }
@@ -249,6 +344,14 @@ function multiSelect(title, items, defaults, previewDir) {
249
344
  return;
250
345
  }
251
346
 
347
+ // a — toggle all
348
+ if (key === "a") {
349
+ const allChecked = checked.every(Boolean);
350
+ checked.fill(!allChecked);
351
+ render(false);
352
+ return;
353
+ }
354
+
252
355
  // p — preview sound
253
356
  if (key === "p" && previewDir && items[cursor].file) {
254
357
  const soundPath = path.join(previewDir, items[cursor].file);
@@ -256,8 +359,8 @@ function multiSelect(title, items, defaults, previewDir) {
256
359
  return;
257
360
  }
258
361
 
259
- // Enter — confirm
260
- if (key === "\r" || key === "\n") {
362
+ // Enter or right arrow — confirm
363
+ if (key === "\r" || key === "\n" || key === "\x1b[C") {
261
364
  process.stdin.setRawMode(false);
262
365
  process.stdin.pause();
263
366
  process.stdin.removeListener("data", onKey);
@@ -375,6 +478,183 @@ function uninstall() {
375
478
  print("");
376
479
  }
377
480
 
481
+ // ─── Sound Item Builder ─────────────────────────────────────────────────────
482
+
483
+ /**
484
+ * Build the full list of sound items for a category.
485
+ * Native sounds (from this category's theme.json entry) come first,
486
+ * then borrowed sounds from all other categories, deduplicated by filename.
487
+ */
488
+ function buildCategoryItems(theme, category) {
489
+ const config = theme.sounds[category];
490
+ const categories = Object.keys(theme.sounds);
491
+ const items = [];
492
+ const seen = new Set();
493
+
494
+ // Build a map of filename -> list of hooks it appears in
495
+ const hookMap = {};
496
+ for (const cat of categories) {
497
+ for (const f of theme.sounds[cat].files) {
498
+ if (!hookMap[f.name]) hookMap[f.name] = [];
499
+ if (!hookMap[f.name].includes(cat)) hookMap[f.name].push(cat);
500
+ }
501
+ }
502
+
503
+ // Native sounds first
504
+ for (const f of config.files) {
505
+ seen.add(f.name);
506
+ items.push({
507
+ label: f.name.replace(/\.(wav|mp3)$/, ""),
508
+ description: hookMap[f.name].join(", "),
509
+ file: f.name,
510
+ src: f.src,
511
+ native: true,
512
+ originCat: category,
513
+ });
514
+ }
515
+
516
+ // Borrowed sounds from other categories
517
+ for (const otherCat of categories) {
518
+ if (otherCat === category) continue;
519
+ for (const f of theme.sounds[otherCat].files) {
520
+ if (seen.has(f.name)) continue;
521
+ seen.add(f.name);
522
+ items.push({
523
+ label: f.name.replace(/\.(wav|mp3)$/, ""),
524
+ description: hookMap[f.name].join(", "),
525
+ file: f.name,
526
+ src: f.src,
527
+ native: false,
528
+ originCat: otherCat,
529
+ });
530
+ }
531
+ }
532
+
533
+ return items;
534
+ }
535
+
536
+ /**
537
+ * Resolve a sound file's source from download (tmpDir/<srcBase>/...).
538
+ */
539
+ function resolveDownloadSrc(srcBase, src) {
540
+ if (src.startsWith("@soundfxcenter/")) {
541
+ return path.join(srcBase, path.basename(src));
542
+ }
543
+ return path.join(srcBase, src);
544
+ }
545
+
546
+ // ─── Reconfigure Flow ───────────────────────────────────────────────────────
547
+
548
+ async function reconfigure(existingInstall) {
549
+ const themeDir = path.join(THEMES_DIR, existingInstall.theme);
550
+ const theme = JSON.parse(fs.readFileSync(path.join(themeDir, "theme.json"), "utf-8"));
551
+ const categories = Object.keys(theme.sounds);
552
+ const tmpDirs = [];
553
+
554
+ try {
555
+ let catIdx = 0;
556
+ while (catIdx < categories.length) {
557
+ const cat = categories[catIdx];
558
+ const config = theme.sounds[cat];
559
+ const catDir = path.join(SOUNDS_DIR, cat);
560
+ const disabledDir = path.join(catDir, ".disabled");
561
+ const items = buildCategoryItems(theme, cat);
562
+
563
+ // Determine current state: checked if file exists in category dir
564
+ const defaults = [];
565
+ for (let i = 0; i < items.length; i++) {
566
+ if (fs.existsSync(path.join(catDir, items[i].file))) {
567
+ defaults.push(i);
568
+ }
569
+ }
570
+
571
+ // Build preview dir with all sounds from all possible locations
572
+ const previewDir = fs.mkdtempSync(path.join(os.tmpdir(), `claude-preview-`));
573
+ tmpDirs.push(previewDir);
574
+ for (const item of items) {
575
+ const originCatDir = path.join(SOUNDS_DIR, item.originCat);
576
+ const originDisabledDir = path.join(originCatDir, ".disabled");
577
+ const searchDirs = [catDir, disabledDir, originCatDir, originDisabledDir];
578
+ for (const dir of searchDirs) {
579
+ const p = path.join(dir, item.file);
580
+ if (fs.existsSync(p)) {
581
+ fs.copyFileSync(p, path.join(previewDir, item.file));
582
+ break;
583
+ }
584
+ }
585
+ }
586
+
587
+ const selected = await multiSelect(
588
+ `${BOLD}${cat}${RESET} ${DIM}— ${config.description}${RESET}`,
589
+ items,
590
+ defaults,
591
+ previewDir,
592
+ { allowBack: catIdx > 0 }
593
+ );
594
+
595
+ // Back was pressed — go to previous category
596
+ if (selected === null) {
597
+ catIdx--;
598
+ continue;
599
+ }
600
+
601
+ // Apply changes
602
+ for (let i = 0; i < items.length; i++) {
603
+ const item = items[i];
604
+ const isSelected = selected.includes(i);
605
+ const enabledPath = path.join(catDir, item.file);
606
+
607
+ if (item.native) {
608
+ const disabledPath = path.join(disabledDir, item.file);
609
+ if (isSelected && !fs.existsSync(enabledPath) && fs.existsSync(disabledPath)) {
610
+ fs.renameSync(disabledPath, enabledPath);
611
+ } else if (!isSelected && fs.existsSync(enabledPath)) {
612
+ mkdirp(disabledDir);
613
+ fs.renameSync(enabledPath, disabledPath);
614
+ }
615
+ } else {
616
+ // Borrowed sound: copy in or delete
617
+ if (isSelected && !fs.existsSync(enabledPath)) {
618
+ const previewFile = path.join(previewDir, item.file);
619
+ if (fs.existsSync(previewFile)) {
620
+ fs.copyFileSync(previewFile, enabledPath);
621
+ }
622
+ } else if (!isSelected && fs.existsSync(enabledPath)) {
623
+ fs.unlinkSync(enabledPath);
624
+ }
625
+ }
626
+ }
627
+
628
+ catIdx++;
629
+ }
630
+ } finally {
631
+ for (const dir of tmpDirs) {
632
+ fs.rmSync(dir, { recursive: true, force: true });
633
+ }
634
+ }
635
+
636
+ // Summary
637
+ let total = 0;
638
+ print(` ${GREEN}✓${RESET} Configuration updated!`);
639
+ print(" ─────────────────────────────────────");
640
+
641
+ for (const cat of categories) {
642
+ const catDir = path.join(SOUNDS_DIR, cat);
643
+ let count = 0;
644
+ try {
645
+ for (const f of fs.readdirSync(catDir)) {
646
+ if (f.endsWith(".wav") || f.endsWith(".mp3")) count++;
647
+ }
648
+ } catch {}
649
+ total += count;
650
+ print(` ${cat} (${count}) — ${theme.sounds[cat].description}`);
651
+ }
652
+
653
+ print("");
654
+ print(` ${total} sound files across ${categories.length} events.`);
655
+ print("");
656
+ }
657
+
378
658
  // ─── Install Flow ───────────────────────────────────────────────────────────
379
659
 
380
660
  async function interactiveInstall(autoYes) {
@@ -383,6 +663,31 @@ async function interactiveInstall(autoYes) {
383
663
  print(" ──────────────────────────────");
384
664
  print("");
385
665
 
666
+ // ── Detect existing install ───────────────────────────────────────────────
667
+
668
+ const existingInstall = getExistingInstall();
669
+
670
+ if (existingInstall && !autoYes) {
671
+ print(` ${GREEN}✓${RESET} Already installed — ${BOLD}${existingInstall.themeDisplay}${RESET}`);
672
+ print(` ${existingInstall.totalEnabled}/${existingInstall.totalAvailable} sounds enabled\n`);
673
+
674
+ const actionIdx = await select("What would you like to do?", [
675
+ { label: "Reconfigure", description: "Choose which sounds are enabled" },
676
+ { label: "Reinstall", description: "Re-download and start fresh" },
677
+ { label: "Uninstall", description: "Remove all sounds and hooks" },
678
+ ]);
679
+
680
+ if (actionIdx === 0) {
681
+ await reconfigure(existingInstall);
682
+ return;
683
+ }
684
+ if (actionIdx === 2) {
685
+ uninstall();
686
+ return;
687
+ }
688
+ // actionIdx === 1 falls through to full install
689
+ }
690
+
386
691
  // ── Step 1: Dependency Check ──────────────────────────────────────────────
387
692
 
388
693
  const deps = ["afplay", "curl", "unzip"];
@@ -469,10 +774,14 @@ async function interactiveInstall(autoYes) {
469
774
 
470
775
  // ── Step 4: Customize or Accept Defaults ──────────────────────────────
471
776
 
472
- // Build a selection map: category -> array of file indices to include
777
+ // Build items and selections for each category (includes all theme sounds)
778
+ const categoryItems = {};
473
779
  const selections = {};
474
780
  for (const cat of categories) {
475
- selections[cat] = theme.sounds[cat].files.map((_, i) => i);
781
+ const items = buildCategoryItems(theme, cat);
782
+ categoryItems[cat] = items;
783
+ // Default: select only native sounds
784
+ selections[cat] = items.map((item, i) => item.native ? i : -1).filter(i => i >= 0);
476
785
  }
477
786
 
478
787
  if (!autoYes) {
@@ -483,29 +792,21 @@ async function interactiveInstall(autoYes) {
483
792
  const customizeIdx = await select("Customize sounds for each hook?", customizeOptions);
484
793
 
485
794
  if (customizeIdx === 1) {
486
- // Customize each category
487
- for (const cat of categories) {
795
+ const srcBase = path.join(tmpDir, theme.srcBase || "Orc");
796
+ let catIdx = 0;
797
+
798
+ while (catIdx < categories.length) {
799
+ const cat = categories[catIdx];
488
800
  const config = theme.sounds[cat];
489
- const items = config.files.map((f) => ({
490
- label: f.name.replace(/\.(wav|mp3)$/, ""),
491
- description: f.description || "",
492
- file: f.name,
493
- }));
494
- const defaults = config.files.map((_, i) => i); // all selected by default
495
-
496
- // Build preview dir: sounds are in tmpDir/Orc/... but we need them by name
497
- // Copy files to a temp preview dir first
801
+ const items = categoryItems[cat];
802
+ const defaults = selections[cat];
803
+
804
+ // Build preview dir with ALL theme sounds
498
805
  const previewDir = path.join(tmpDir, "_preview", cat);
499
806
  mkdirp(previewDir);
500
- const srcBase = path.join(tmpDir, "Orc");
501
- for (const file of config.files) {
502
- let srcFile;
503
- if (file.src.startsWith("@soundfxcenter/")) {
504
- srcFile = path.join(srcBase, path.basename(file.src));
505
- } else {
506
- srcFile = path.join(srcBase, file.src);
507
- }
508
- const destFile = path.join(previewDir, file.name);
807
+ for (const item of items) {
808
+ const srcFile = resolveDownloadSrc(srcBase, item.src);
809
+ const destFile = path.join(previewDir, item.file);
509
810
  if (fs.existsSync(srcFile)) {
510
811
  fs.copyFileSync(srcFile, destFile);
511
812
  }
@@ -515,9 +816,17 @@ async function interactiveInstall(autoYes) {
515
816
  `${BOLD}${cat}${RESET} ${DIM}— ${config.description}${RESET}`,
516
817
  items,
517
818
  defaults,
518
- previewDir
819
+ previewDir,
820
+ { allowBack: catIdx > 0 }
519
821
  );
822
+
823
+ if (selected === null) {
824
+ catIdx--;
825
+ continue;
826
+ }
827
+
520
828
  selections[cat] = selected;
829
+ catIdx++;
521
830
  }
522
831
  }
523
832
  }
@@ -526,40 +835,54 @@ async function interactiveInstall(autoYes) {
526
835
 
527
836
  print(" Installing sounds...");
528
837
 
529
- // Clear existing sounds
838
+ // Clear existing sounds and .disabled dirs
530
839
  for (const cat of categories) {
531
840
  const catDir = path.join(SOUNDS_DIR, cat);
532
841
  for (const f of fs.readdirSync(catDir)) {
533
- if (f.endsWith(".wav") || f.endsWith(".mp3")) {
534
- fs.unlinkSync(path.join(catDir, f));
842
+ const fp = path.join(catDir, f);
843
+ if (f === ".disabled") {
844
+ fs.rmSync(fp, { recursive: true, force: true });
845
+ } else if (f.endsWith(".wav") || f.endsWith(".mp3")) {
846
+ fs.unlinkSync(fp);
535
847
  }
536
848
  }
537
849
  }
538
850
 
539
- // Copy selected files
540
- const srcBase = path.join(tmpDir, "Orc");
851
+ // Copy files from download based on selections
852
+ const srcBase = path.join(tmpDir, theme.srcBase || "Orc");
541
853
  let total = 0;
542
- for (const [category, config] of Object.entries(theme.sounds)) {
543
- const selectedIndices = selections[category];
544
- for (const idx of selectedIndices) {
545
- const file = config.files[idx];
546
- let srcFile;
547
- if (file.src.startsWith("@soundfxcenter/")) {
548
- srcFile = path.join(srcBase, path.basename(file.src));
549
- } else {
550
- srcFile = path.join(srcBase, file.src);
854
+ for (const cat of categories) {
855
+ const items = categoryItems[cat];
856
+ const selectedIndices = selections[cat];
857
+ const catDir = path.join(SOUNDS_DIR, cat);
858
+ const disabledDir = path.join(catDir, ".disabled");
859
+
860
+ for (let i = 0; i < items.length; i++) {
861
+ const item = items[i];
862
+ const srcFile = resolveDownloadSrc(srcBase, item.src);
863
+
864
+ if (!fs.existsSync(srcFile)) {
865
+ if (item.native) {
866
+ print(` ${YELLOW}⚠${RESET} ${item.src} not found, skipping`);
867
+ }
868
+ continue;
551
869
  }
552
870
 
553
- const destFile = path.join(SOUNDS_DIR, category, file.name);
554
- if (fs.existsSync(srcFile)) {
555
- fs.copyFileSync(srcFile, destFile);
871
+ if (selectedIndices.includes(i)) {
872
+ fs.copyFileSync(srcFile, path.join(catDir, item.file));
556
873
  total++;
557
- } else {
558
- print(` ${YELLOW}⚠${RESET} ${file.src} not found, skipping`);
874
+ } else if (item.native) {
875
+ // Save unselected native sounds to .disabled for future reconfigure
876
+ mkdirp(disabledDir);
877
+ fs.copyFileSync(srcFile, path.join(disabledDir, item.file));
559
878
  }
879
+ // Unselected borrowed sounds: skip (no need to store)
560
880
  }
561
881
  }
562
882
 
883
+ // Write install marker
884
+ writeInstalled(selectedTheme.name);
885
+
563
886
  // Copy play-sound.sh hook
564
887
  const hookSrc = path.join(PKG_DIR, "hooks", "play-sound.sh");
565
888
  const hookDest = path.join(HOOKS_DIR, "play-sound.sh");
@@ -576,11 +899,9 @@ async function interactiveInstall(autoYes) {
576
899
  print(` ${GREEN}✓${RESET} Installed! Here's what you'll hear:`);
577
900
  print(" ─────────────────────────────────────");
578
901
 
579
- for (const [cat, config] of Object.entries(theme.sounds)) {
902
+ for (const cat of categories) {
580
903
  const count = selections[cat].length;
581
- const totalAvailable = config.files.length;
582
- const suffix = count < totalAvailable ? ` (${count}/${totalAvailable})` : ` (${count})`;
583
- print(` ${cat}${suffix} — ${config.description}`);
904
+ print(` ${cat} (${count}) ${theme.sounds[cat].description}`);
584
905
  }
585
906
 
586
907
  print("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-sounds",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Sound themes for Claude Code lifecycle hooks",
5
5
  "bin": {
6
6
  "claude-code-sounds": "bin/cli.js"
@@ -5,6 +5,7 @@
5
5
  #
6
6
  set -e
7
7
 
8
+ # shellcheck disable=SC2034 # passed by install.sh, reserved for theme use
8
9
  SOUNDS_DIR="$1"
9
10
  TMP_DIR="$2"
10
11
 
@@ -14,7 +15,7 @@ curl -sL -o "$ZIP" "https://sounds.spriters-resource.com/media/assets/422/425494
14
15
 
15
16
  if ! file "$ZIP" | grep -q "Zip"; then
16
17
  echo " Error: Failed to download WC3 sound pack."
17
- return 1
18
+ exit 1
18
19
  fi
19
20
 
20
21
  unzip -qo "$ZIP" \
@@ -2,6 +2,7 @@
2
2
  "name": "WC3 Orc Peon",
3
3
  "description": "Warcraft 3 Orc unit soundbites — Peons, Grunts, Shamans, and more",
4
4
  "author": "ryparker",
5
+ "srcBase": "Orc",
5
6
  "sounds": {
6
7
  "start": {
7
8
  "description": "Session starting — being summoned",
@@ -34,7 +35,8 @@
34
35
  "stop": {
35
36
  "description": "Done responding — acknowledgment",
36
37
  "files": [
37
- { "src": "Peon/PeonYesAttack2.wav", "name": "zug-zug.wav" },
38
+ { "src": "Grunt/GruntYes4.wav", "name": "zug-zug.wav" },
39
+ { "src": "Peon/PeonYesAttack2.wav", "name": "get-em.wav" },
38
40
  { "src": "Peon/PeonYesAttack1.wav", "name": "ok.wav" },
39
41
  { "src": "Peon/PeonYes1.wav", "name": "i-can-do-that.wav" },
40
42
  { "src": "Peon/PeonYes2.wav", "name": "be-happy-to.wav" },
@@ -52,7 +54,8 @@
52
54
  { "src": "Hellscream/GromWarcry1.wav", "name": "taste-the-fury.wav" },
53
55
  { "src": "Peon/PeonYesAttack3.wav", "name": "ill-try.wav" },
54
56
  { "src": "Peon/PeonWarcry1.wav", "name": "why-not.wav" },
55
- { "src": "Peon/PeonYesAttack2.wav", "name": "zug-zug.wav" }
57
+ { "src": "Grunt/GruntYes4.wav", "name": "zug-zug.wav" },
58
+ { "src": "Peon/PeonYesAttack2.wav", "name": "get-em.wav" }
56
59
  ]
57
60
  },
58
61
  "idle": {
@@ -0,0 +1,67 @@
1
+ #!/bin/bash
2
+ #
3
+ # Downloads sound files for the zelda-oot theme.
4
+ # Called by install.sh with $1 = target sounds directory, $2 = temp directory.
5
+ #
6
+ set -e
7
+
8
+ # shellcheck disable=SC2034 # passed by install.sh, reserved for theme use
9
+ SOUNDS_DIR="$1"
10
+ TMP_DIR="$2"
11
+ OOT_DIR="$TMP_DIR/OOT"
12
+
13
+ mkdir -p "$OOT_DIR"
14
+
15
+ BASE_URL="https://noproblo.dayjo.org/zeldasounds/oot"
16
+
17
+ # All unique sound files needed by this theme
18
+ FILES=(
19
+ OOT_Navi_Hey1.wav
20
+ OOT_Navi_Hello1.wav
21
+ OOT_Navi_Listen1.wav
22
+ OOT_Navi_Look1.wav
23
+ OOT_Navi_WatchOut1.wav
24
+ OOT_GreatFairy_Laugh1.wav
25
+ OOT_Chest_Big.wav
26
+ OOT_Chest_Small.wav
27
+ OOT_MainMenu_Select.wav
28
+ OOT_Dialogue_Next.wav
29
+ OOT_Dialogue_Done_Mono.wav
30
+ OOT_Get_Heart.wav
31
+ OOT_Get_SmallItem1.wav
32
+ OOT_Get_Rupee.wav
33
+ OOT_Secret_Mono.wav
34
+ OOT_Song_Correct_Mono.wav
35
+ OOT_Song_Error.wav
36
+ OOT_PauseMenu_Open_Mono.wav
37
+ OOT_PauseMenu_Close_Mono.wav
38
+ OOT_ZTarget_Enemy.wav
39
+ OOT_Sword_Draw.wav
40
+ OOT_Sword_Spin.wav
41
+ OOT_AdultLink_Attack1.wav
42
+ OOT_AdultLink_StrongAttack1.wav
43
+ OOT_AdultLink_Hurt1.wav
44
+ OOT_Fanfare_SmallItem.wav
45
+ OOT_Error.wav
46
+ OOT_LowHealth.wav
47
+ OOT_Talon_Snore.wav
48
+ OOT_Ganondorf_Heheh.wav
49
+ )
50
+
51
+ echo " Downloading Zelda: Ocarina of Time sounds..."
52
+
53
+ FAILED=0
54
+ for FILE in "${FILES[@]}"; do
55
+ curl -sL -o "$OOT_DIR/$FILE" "$BASE_URL/$FILE"
56
+ if ! file "$OOT_DIR/$FILE" | grep -q "WAVE\|RIFF"; then
57
+ echo " Warning: Failed to download $FILE"
58
+ rm -f "$OOT_DIR/$FILE"
59
+ FAILED=$((FAILED + 1))
60
+ fi
61
+ done
62
+
63
+ if [ "$FAILED" -gt 0 ]; then
64
+ echo " Warning: $FAILED file(s) failed to download."
65
+ fi
66
+
67
+ echo " Download complete."
@@ -0,0 +1,110 @@
1
+ {
2
+ "name": "Zelda: Ocarina of Time",
3
+ "description": "Navi, Link, and iconic OOT jingles — Hey! Listen!",
4
+ "author": "ryparker",
5
+ "srcBase": "OOT",
6
+ "sounds": {
7
+ "start": {
8
+ "description": "Session starting — Navi greets you",
9
+ "files": [
10
+ { "src": "OOT_Navi_Hey1.wav", "name": "hey.wav" },
11
+ { "src": "OOT_Navi_Hello1.wav", "name": "hello.wav" },
12
+ { "src": "OOT_Navi_Listen1.wav", "name": "listen.wav" },
13
+ { "src": "OOT_GreatFairy_Laugh1.wav", "name": "great-fairy-laugh.wav" },
14
+ { "src": "OOT_Chest_Big.wav", "name": "open-chest.wav" }
15
+ ]
16
+ },
17
+ "end": {
18
+ "description": "Session over — quest complete",
19
+ "files": [
20
+ { "src": "OOT_Fanfare_SmallItem.wav", "name": "item-fanfare.wav" },
21
+ { "src": "OOT_Secret_Mono.wav", "name": "secret-discovered.wav" },
22
+ { "src": "OOT_Dialogue_Done_Mono.wav", "name": "dialogue-done.wav" }
23
+ ]
24
+ },
25
+ "permission": {
26
+ "description": "Permission prompt — Navi warns you",
27
+ "files": [
28
+ { "src": "OOT_Navi_WatchOut1.wav", "name": "watch-out.wav" },
29
+ { "src": "OOT_Navi_Hey1.wav", "name": "hey.wav" },
30
+ { "src": "OOT_Navi_Listen1.wav", "name": "listen.wav" },
31
+ { "src": "OOT_PauseMenu_Open_Mono.wav", "name": "pause-menu.wav" },
32
+ { "src": "OOT_ZTarget_Enemy.wav", "name": "z-target.wav" }
33
+ ]
34
+ },
35
+ "stop": {
36
+ "description": "Done responding — loot collected",
37
+ "files": [
38
+ { "src": "OOT_Secret_Mono.wav", "name": "secret-discovered.wav" },
39
+ { "src": "OOT_Get_SmallItem1.wav", "name": "get-item.wav" },
40
+ { "src": "OOT_Get_Rupee.wav", "name": "get-rupee.wav" },
41
+ { "src": "OOT_Chest_Small.wav", "name": "open-small-chest.wav" },
42
+ { "src": "OOT_Song_Correct_Mono.wav", "name": "song-correct.wav" }
43
+ ]
44
+ },
45
+ "subagent": {
46
+ "description": "Spawning subagent — Link attacks",
47
+ "files": [
48
+ { "src": "OOT_Sword_Draw.wav", "name": "sword-draw.wav" },
49
+ { "src": "OOT_AdultLink_Attack1.wav", "name": "link-attack.wav" },
50
+ { "src": "OOT_AdultLink_StrongAttack1.wav", "name": "link-strong-attack.wav" },
51
+ { "src": "OOT_Navi_Hey1.wav", "name": "hey.wav" },
52
+ { "src": "OOT_Sword_Spin.wav", "name": "spin-attack.wav" }
53
+ ]
54
+ },
55
+ "idle": {
56
+ "description": "Waiting for input — Navi pesters you",
57
+ "files": [
58
+ { "src": "OOT_Navi_Hey1.wav", "name": "hey.wav" },
59
+ { "src": "OOT_Navi_Listen1.wav", "name": "listen.wav" },
60
+ { "src": "OOT_Navi_WatchOut1.wav", "name": "watch-out.wav" },
61
+ { "src": "OOT_LowHealth.wav", "name": "low-health.wav" },
62
+ { "src": "OOT_Talon_Snore.wav", "name": "snore.wav" }
63
+ ]
64
+ },
65
+ "error": {
66
+ "description": "Tool failure — Link takes damage",
67
+ "files": [
68
+ { "src": "OOT_Error.wav", "name": "error.wav" },
69
+ { "src": "OOT_Song_Error.wav", "name": "song-error.wav" },
70
+ { "src": "OOT_AdultLink_Hurt1.wav", "name": "link-hurt.wav" },
71
+ { "src": "OOT_Ganondorf_Heheh.wav", "name": "ganondorf-laugh.wav" }
72
+ ]
73
+ },
74
+ "prompt": {
75
+ "description": "User submitted prompt — adventure continues",
76
+ "files": [
77
+ { "src": "OOT_Navi_Hey1.wav", "name": "hey.wav" },
78
+ { "src": "OOT_Navi_Look1.wav", "name": "look.wav" },
79
+ { "src": "OOT_MainMenu_Select.wav", "name": "menu-select.wav" },
80
+ { "src": "OOT_Dialogue_Next.wav", "name": "dialogue-next.wav" },
81
+ { "src": "OOT_Get_Heart.wav", "name": "get-heart.wav" }
82
+ ]
83
+ },
84
+ "task-completed": {
85
+ "description": "Task finished — treasure acquired",
86
+ "files": [
87
+ { "src": "OOT_Fanfare_SmallItem.wav", "name": "item-fanfare.wav" },
88
+ { "src": "OOT_Secret_Mono.wav", "name": "secret-discovered.wav" }
89
+ ]
90
+ },
91
+ "compact": {
92
+ "description": "Context compaction — health running low",
93
+ "files": [
94
+ { "src": "OOT_LowHealth.wav", "name": "low-health.wav" },
95
+ { "src": "OOT_PauseMenu_Close_Mono.wav", "name": "pause-close.wav" },
96
+ { "src": "OOT_Navi_WatchOut1.wav", "name": "watch-out.wav" },
97
+ { "src": "OOT_Error.wav", "name": "error.wav" }
98
+ ]
99
+ },
100
+ "teammate-idle": {
101
+ "description": "Teammate went idle — Navi nags",
102
+ "files": [
103
+ { "src": "OOT_Navi_Hey1.wav", "name": "hey.wav" },
104
+ { "src": "OOT_Navi_Listen1.wav", "name": "listen.wav" },
105
+ { "src": "OOT_Navi_WatchOut1.wav", "name": "watch-out.wav" },
106
+ { "src": "OOT_LowHealth.wav", "name": "low-health.wav" }
107
+ ]
108
+ }
109
+ }
110
+ }