claude-code-sounds 1.0.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/bin/cli.js CHANGED
@@ -3,7 +3,8 @@
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const os = require("os");
6
- const { execSync } = require("child_process");
6
+ const readline = require("readline");
7
+ const { execSync, spawn } = require("child_process");
7
8
 
8
9
  // ─── Paths ───────────────────────────────────────────────────────────────────
9
10
 
@@ -13,15 +14,16 @@ const SOUNDS_DIR = path.join(CLAUDE_DIR, "sounds");
13
14
  const HOOKS_DIR = path.join(CLAUDE_DIR, "hooks");
14
15
  const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
15
16
  const THEMES_DIR = path.join(PKG_DIR, "themes");
17
+ const INSTALLED_PATH = path.join(SOUNDS_DIR, ".installed.json");
16
18
 
17
19
  // ─── Helpers ─────────────────────────────────────────────────────────────────
18
20
 
19
21
  function print(msg = "") {
20
- console.log(msg);
22
+ process.stdout.write(msg + "\n");
21
23
  }
22
24
 
23
25
  function die(msg) {
24
- console.error(`Error: ${msg}`);
26
+ console.error(`\n Error: ${msg}\n`);
25
27
  process.exit(1);
26
28
  }
27
29
 
@@ -56,6 +58,350 @@ function writeSettings(settings) {
56
58
  fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
57
59
  }
58
60
 
61
+ function hasCommand(name) {
62
+ try {
63
+ exec(`which ${name}`);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
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
+
117
+ // ─── ANSI helpers ────────────────────────────────────────────────────────────
118
+
119
+ const CSI = "\x1b[";
120
+ const CLEAR_LINE = `${CSI}2K`;
121
+ const HIDE_CURSOR = `${CSI}?25l`;
122
+ const SHOW_CURSOR = `${CSI}?25h`;
123
+ const BOLD = `${CSI}1m`;
124
+ const DIM = `${CSI}2m`;
125
+ const RESET = `${CSI}0m`;
126
+ const GREEN = `${CSI}32m`;
127
+ const RED = `${CSI}31m`;
128
+ const CYAN = `${CSI}36m`;
129
+ const YELLOW = `${CSI}33m`;
130
+
131
+ function moveCursorUp(n) {
132
+ if (n > 0) process.stdout.write(`${CSI}${n}A`);
133
+ }
134
+
135
+ function clearLines(n) {
136
+ for (let i = 0; i < n; i++) {
137
+ process.stdout.write(`${CLEAR_LINE}\n`);
138
+ }
139
+ moveCursorUp(n);
140
+ }
141
+
142
+ // ─── Interactive UI ──────────────────────────────────────────────────────────
143
+
144
+ let previewProcess = null;
145
+
146
+ function killPreview() {
147
+ if (previewProcess) {
148
+ try { previewProcess.kill(); } catch {}
149
+ previewProcess = null;
150
+ }
151
+ }
152
+
153
+ function playPreview(filePath) {
154
+ killPreview();
155
+ if (fs.existsSync(filePath)) {
156
+ previewProcess = spawn("afplay", [filePath], { stdio: "ignore", detached: true });
157
+ previewProcess.unref();
158
+ previewProcess.on("exit", () => { previewProcess = null; });
159
+ }
160
+ }
161
+
162
+ function cleanupAndExit() {
163
+ killPreview();
164
+ process.stdout.write(SHOW_CURSOR);
165
+ print("\n");
166
+ process.exit(0);
167
+ }
168
+
169
+ /**
170
+ * Single-select menu with arrow keys.
171
+ * Returns the index of the chosen option.
172
+ */
173
+ function select(title, options) {
174
+ return new Promise((resolve) => {
175
+ let cursor = 0;
176
+ const lineCount = options.length + 3; // title + blank + options + hint
177
+
178
+ function render(initial) {
179
+ if (!initial) moveCursorUp(lineCount);
180
+ print(` ${title}\n`);
181
+ for (let i = 0; i < options.length; i++) {
182
+ const prefix = i === cursor ? `${CYAN} ❯ ` : " ";
183
+ const label = options[i].label;
184
+ const desc = options[i].description ? ` ${DIM}— ${options[i].description}${RESET}` : "";
185
+ print(`${prefix}${RESET}${i === cursor ? BOLD : ""}${label}${RESET}${desc}`);
186
+ }
187
+ print(`${DIM} ↑↓ navigate · enter select${RESET}`);
188
+ }
189
+
190
+ process.stdout.write(HIDE_CURSOR);
191
+ render(true);
192
+
193
+ process.stdin.setRawMode(true);
194
+ process.stdin.resume();
195
+ process.stdin.setEncoding("utf-8");
196
+
197
+ function onKey(key) {
198
+ // Ctrl+C or q
199
+ if (key === "\x03" || key === "q") {
200
+ process.stdin.setRawMode(false);
201
+ process.stdin.pause();
202
+ process.stdin.removeListener("data", onKey);
203
+ cleanupAndExit();
204
+ return;
205
+ }
206
+
207
+ // Arrow up
208
+ if (key === "\x1b[A" || key === "k") {
209
+ cursor = (cursor - 1 + options.length) % options.length;
210
+ render(false);
211
+ return;
212
+ }
213
+
214
+ // Arrow down
215
+ if (key === "\x1b[B" || key === "j") {
216
+ cursor = (cursor + 1) % options.length;
217
+ render(false);
218
+ return;
219
+ }
220
+
221
+ // Enter
222
+ if (key === "\r" || key === "\n") {
223
+ process.stdin.setRawMode(false);
224
+ process.stdin.pause();
225
+ process.stdin.removeListener("data", onKey);
226
+ // Redraw final state
227
+ moveCursorUp(lineCount);
228
+ clearLines(lineCount);
229
+ print(` ${title} ${GREEN}${options[cursor].label}${RESET}\n`);
230
+ process.stdout.write(SHOW_CURSOR);
231
+ resolve(cursor);
232
+ return;
233
+ }
234
+ }
235
+
236
+ process.stdin.on("data", onKey);
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Multi-select checklist with toggle, preview, and confirm.
242
+ * Returns array of selected indices, or null if back was pressed.
243
+ */
244
+ function multiSelect(title, items, defaults, previewDir, { allowBack = false } = {}) {
245
+ return new Promise((resolve) => {
246
+ let cursor = 0;
247
+ let scrollTop = 0;
248
+ const checked = items.map((_, i) => defaults.includes(i));
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
+ }
263
+
264
+ function render(initial) {
265
+ if (!initial) moveCursorUp(lineCount);
266
+ print(` ${title}\n`);
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
+ }
288
+ }
289
+
290
+ const previewHint = previewDir ? " · p preview" : "";
291
+ const backHint = allowBack ? "← back · " : "";
292
+ print(`${DIM} ${backHint}↑↓ navigate · space toggle · a all${previewHint} · →/enter confirm${RESET}`);
293
+ }
294
+
295
+ process.stdout.write(HIDE_CURSOR);
296
+ adjustScroll();
297
+ render(true);
298
+
299
+ process.stdin.setRawMode(true);
300
+ process.stdin.resume();
301
+ process.stdin.setEncoding("utf-8");
302
+
303
+ function onKey(key) {
304
+ if (key === "\x03" || key === "q") {
305
+ process.stdin.setRawMode(false);
306
+ process.stdin.pause();
307
+ process.stdin.removeListener("data", onKey);
308
+ killPreview();
309
+ cleanupAndExit();
310
+ return;
311
+ }
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
+
326
+ if (key === "\x1b[A" || key === "k") {
327
+ cursor = (cursor - 1 + items.length) % items.length;
328
+ adjustScroll();
329
+ render(false);
330
+ return;
331
+ }
332
+
333
+ if (key === "\x1b[B" || key === "j") {
334
+ cursor = (cursor + 1) % items.length;
335
+ adjustScroll();
336
+ render(false);
337
+ return;
338
+ }
339
+
340
+ // Space — toggle
341
+ if (key === " ") {
342
+ checked[cursor] = !checked[cursor];
343
+ render(false);
344
+ return;
345
+ }
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
+
355
+ // p — preview sound
356
+ if (key === "p" && previewDir && items[cursor].file) {
357
+ const soundPath = path.join(previewDir, items[cursor].file);
358
+ playPreview(soundPath);
359
+ return;
360
+ }
361
+
362
+ // Enter or right arrow — confirm
363
+ if (key === "\r" || key === "\n" || key === "\x1b[C") {
364
+ process.stdin.setRawMode(false);
365
+ process.stdin.pause();
366
+ process.stdin.removeListener("data", onKey);
367
+ killPreview();
368
+
369
+ const selected = [];
370
+ for (let i = 0; i < checked.length; i++) {
371
+ if (checked[i]) selected.push(i);
372
+ }
373
+
374
+ // Redraw final state
375
+ moveCursorUp(lineCount);
376
+ clearLines(lineCount);
377
+ const count = selected.length;
378
+ print(` ${title} ${GREEN}${count}/${items.length} selected${RESET}\n`);
379
+ process.stdout.write(SHOW_CURSOR);
380
+ resolve(selected);
381
+ return;
382
+ }
383
+ }
384
+
385
+ process.stdin.on("data", onKey);
386
+ });
387
+ }
388
+
389
+ /**
390
+ * Y/n confirmation prompt.
391
+ */
392
+ function confirm(message, defaultYes = true) {
393
+ return new Promise((resolve) => {
394
+ const hint = defaultYes ? "Y/n" : "y/N";
395
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
396
+ rl.question(` ${message} (${hint}) `, (answer) => {
397
+ rl.close();
398
+ const a = answer.trim().toLowerCase();
399
+ if (a === "") resolve(defaultYes);
400
+ else resolve(a === "y" || a === "yes");
401
+ });
402
+ });
403
+ }
404
+
59
405
  // ─── Hooks Config ────────────────────────────────────────────────────────────
60
406
 
61
407
  const HOOKS_CONFIG = {
@@ -74,7 +420,7 @@ const HOOKS_CONFIG = {
74
420
  TeammateIdle: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" teammate-idle', timeout: 5 }] }],
75
421
  };
76
422
 
77
- // ─── Commands ────────────────────────────────────────────────────────────────
423
+ // ─── Non-Interactive Commands ───────────────────────────────────────────────
78
424
 
79
425
  function showHelp() {
80
426
  print("");
@@ -82,11 +428,16 @@ function showHelp() {
82
428
  print(" ──────────────────────────────");
83
429
  print("");
84
430
  print(" Usage:");
85
- print(" npx claude-code-sounds Install default theme (wc3-peon)");
86
- print(" npx claude-code-sounds <theme> Install a specific theme");
87
- print(" npx claude-code-sounds --list List available themes");
88
- print(" npx claude-code-sounds --uninstall Remove all sounds and hooks");
89
- print(" npx claude-code-sounds --help Show this help");
431
+ print(" npx claude-code-sounds Interactive install");
432
+ print(" npx claude-code-sounds --yes Install defaults, skip prompts");
433
+ print(" npx claude-code-sounds --list List available themes");
434
+ print(" npx claude-code-sounds --uninstall Remove all sounds and hooks");
435
+ print(" npx claude-code-sounds --help Show this help");
436
+ print("");
437
+ print(" Flags:");
438
+ print(" -y, --yes Skip all prompts, use defaults");
439
+ print(" -l, --list List available themes");
440
+ print(" -h, --help Show this help");
90
441
  print("");
91
442
  }
92
443
 
@@ -106,20 +457,20 @@ function uninstall() {
106
457
 
107
458
  if (fs.existsSync(SOUNDS_DIR)) {
108
459
  fs.rmSync(SOUNDS_DIR, { recursive: true });
109
- print(" Removed ~/.claude/sounds/");
460
+ print(" Removed ~/.claude/sounds/");
110
461
  }
111
462
 
112
463
  const hookScript = path.join(HOOKS_DIR, "play-sound.sh");
113
464
  if (fs.existsSync(hookScript)) {
114
465
  fs.unlinkSync(hookScript);
115
- print(" Removed ~/.claude/hooks/play-sound.sh");
466
+ print(" Removed ~/.claude/hooks/play-sound.sh");
116
467
  }
117
468
 
118
469
  if (fs.existsSync(SETTINGS_PATH)) {
119
470
  const settings = readSettings();
120
471
  delete settings.hooks;
121
472
  writeSettings(settings);
122
- print(" Removed hooks from settings.json");
473
+ print(" Removed hooks from settings.json");
123
474
  }
124
475
 
125
476
  print("");
@@ -127,39 +478,291 @@ function uninstall() {
127
478
  print("");
128
479
  }
129
480
 
130
- function install(themeName) {
131
- const themeDir = path.join(THEMES_DIR, themeName);
132
- const themeJsonPath = path.join(themeDir, "theme.json");
481
+ // ─── Sound Item Builder ─────────────────────────────────────────────────────
133
482
 
134
- if (!fs.existsSync(themeJsonPath)) {
135
- die(`Theme '${themeName}' not found.\n\nAvailable themes:\n${listThemes().map((t) => ` ${t.name} ${t.description}`).join("\n")}`);
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
+ }
136
501
  }
137
502
 
138
- const theme = JSON.parse(fs.readFileSync(themeJsonPath, "utf-8"));
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"));
139
551
  const categories = Object.keys(theme.sounds);
552
+ const tmpDirs = [];
140
553
 
141
- // Preflight
142
554
  try {
143
- exec("which afplay");
144
- } catch {
145
- die("afplay not found. This tool requires macOS.");
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}`);
146
651
  }
147
652
 
148
653
  print("");
149
- print(" claude-code-sounds");
654
+ print(` ${total} sound files across ${categories.length} events.`);
655
+ print("");
656
+ }
657
+
658
+ // ─── Install Flow ───────────────────────────────────────────────────────────
659
+
660
+ async function interactiveInstall(autoYes) {
661
+ print("");
662
+ print(` ${BOLD}claude-code-sounds${RESET}`);
150
663
  print(" ──────────────────────────────");
151
- print(` Theme: ${theme.name}`);
152
664
  print("");
153
665
 
154
- // 1. Create directories
155
- print(" [1/4] Creating directories...");
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
+
691
+ // ── Step 1: Dependency Check ──────────────────────────────────────────────
692
+
693
+ const deps = ["afplay", "curl", "unzip"];
694
+ const missing = [];
695
+
696
+ print(" Checking dependencies...");
697
+ for (const dep of deps) {
698
+ const ok = hasCommand(dep);
699
+ if (ok) {
700
+ print(` ${GREEN}✓${RESET} ${dep}`);
701
+ } else {
702
+ print(` ${RED}✗${RESET} ${dep} — required${dep === "afplay" ? " (macOS only)" : ""}`);
703
+ missing.push(dep);
704
+ }
705
+ }
706
+ print("");
707
+
708
+ if (missing.includes("afplay")) {
709
+ die("afplay is not available. claude-code-sounds requires macOS.");
710
+ }
711
+
712
+ if (missing.length > 0) {
713
+ if (autoYes) {
714
+ die(`Missing dependencies: ${missing.join(", ")}. Install them and try again.`);
715
+ }
716
+
717
+ const installDeps = await confirm(`Install missing dependencies with Homebrew?`, true);
718
+ if (installDeps) {
719
+ try {
720
+ exec("which brew");
721
+ } catch {
722
+ die("Homebrew not found. Install missing dependencies manually:\n brew install " + missing.join(" "));
723
+ }
724
+ print(` Installing ${missing.join(", ")}...`);
725
+ try {
726
+ exec(`brew install ${missing.join(" ")}`, { stdio: "inherit" });
727
+ print(` ${GREEN}✓${RESET} Dependencies installed.\n`);
728
+ } catch {
729
+ die("Failed to install dependencies. Run manually:\n brew install " + missing.join(" "));
730
+ }
731
+ } else {
732
+ die("Missing dependencies. Install them manually:\n brew install " + missing.join(" "));
733
+ }
734
+ }
735
+
736
+ // ── Step 2: Theme Selection ───────────────────────────────────────────────
737
+
738
+ const themes = listThemes();
739
+ let selectedTheme;
740
+
741
+ if (themes.length === 0) {
742
+ die("No themes found in themes/ directory.");
743
+ } else if (themes.length === 1 || autoYes) {
744
+ selectedTheme = themes[0];
745
+ print(` Theme: ${BOLD}${selectedTheme.display}${RESET} — ${selectedTheme.description}\n`);
746
+ } else {
747
+ const options = themes.map((t) => ({ label: t.display, description: t.description }));
748
+ const idx = await select("Select a theme:", options);
749
+ selectedTheme = themes[idx];
750
+ }
751
+
752
+ // ── Step 3: Download ──────────────────────────────────────────────────────
753
+
754
+ const themeDir = path.join(THEMES_DIR, selectedTheme.name);
755
+ const themeJsonPath = path.join(themeDir, "theme.json");
756
+ const theme = JSON.parse(fs.readFileSync(themeJsonPath, "utf-8"));
757
+ const categories = Object.keys(theme.sounds);
758
+
759
+ // Create directories
156
760
  for (const cat of categories) {
157
761
  mkdirp(path.join(SOUNDS_DIR, cat));
158
762
  }
159
763
  mkdirp(HOOKS_DIR);
160
764
 
161
- // 2. Download sounds
162
- print(" [2/4] Downloading sounds...");
765
+ print(" Downloading sounds...");
163
766
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-sounds-"));
164
767
 
165
768
  try {
@@ -167,44 +770,120 @@ function install(themeName) {
167
770
  if (fs.existsSync(downloadScript)) {
168
771
  exec(`bash "${downloadScript}" "${SOUNDS_DIR}" "${tmpDir}"`, { stdio: "inherit" });
169
772
  }
773
+ print(` ${GREEN}✓${RESET} Download complete.\n`);
774
+
775
+ // ── Step 4: Customize or Accept Defaults ──────────────────────────────
776
+
777
+ // Build items and selections for each category (includes all theme sounds)
778
+ const categoryItems = {};
779
+ const selections = {};
780
+ for (const cat of categories) {
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);
785
+ }
786
+
787
+ if (!autoYes) {
788
+ const customizeOptions = [
789
+ { label: "No, use defaults", description: "Recommended" },
790
+ { label: "Yes, let me pick", description: "Choose sounds per hook" },
791
+ ];
792
+ const customizeIdx = await select("Customize sounds for each hook?", customizeOptions);
793
+
794
+ if (customizeIdx === 1) {
795
+ const srcBase = path.join(tmpDir, theme.srcBase || "Orc");
796
+ let catIdx = 0;
797
+
798
+ while (catIdx < categories.length) {
799
+ const cat = categories[catIdx];
800
+ const config = theme.sounds[cat];
801
+ const items = categoryItems[cat];
802
+ const defaults = selections[cat];
803
+
804
+ // Build preview dir with ALL theme sounds
805
+ const previewDir = path.join(tmpDir, "_preview", cat);
806
+ mkdirp(previewDir);
807
+ for (const item of items) {
808
+ const srcFile = resolveDownloadSrc(srcBase, item.src);
809
+ const destFile = path.join(previewDir, item.file);
810
+ if (fs.existsSync(srcFile)) {
811
+ fs.copyFileSync(srcFile, destFile);
812
+ }
813
+ }
814
+
815
+ const selected = await multiSelect(
816
+ `${BOLD}${cat}${RESET} ${DIM}— ${config.description}${RESET}`,
817
+ items,
818
+ defaults,
819
+ previewDir,
820
+ { allowBack: catIdx > 0 }
821
+ );
822
+
823
+ if (selected === null) {
824
+ catIdx--;
825
+ continue;
826
+ }
827
+
828
+ selections[cat] = selected;
829
+ catIdx++;
830
+ }
831
+ }
832
+ }
833
+
834
+ // ── Step 5: Install & Summary ─────────────────────────────────────────
170
835
 
171
- // 3. Sort sounds
172
- print(" [3/4] Sorting sounds...");
836
+ print(" Installing sounds...");
173
837
 
174
- // Clear existing sounds
838
+ // Clear existing sounds and .disabled dirs
175
839
  for (const cat of categories) {
176
840
  const catDir = path.join(SOUNDS_DIR, cat);
177
841
  for (const f of fs.readdirSync(catDir)) {
178
- if (f.endsWith(".wav") || f.endsWith(".mp3")) {
179
- 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);
180
847
  }
181
848
  }
182
849
  }
183
850
 
184
- // Copy files based on theme.json
185
- const srcBase = path.join(tmpDir, "Orc");
186
- for (const [category, config] of Object.entries(theme.sounds)) {
187
- for (const file of config.files) {
188
- let srcFile;
189
- if (file.src.startsWith("@soundfxcenter/")) {
190
- srcFile = path.join(srcBase, path.basename(file.src));
191
- } else {
192
- srcFile = path.join(srcBase, file.src);
851
+ // Copy files from download based on selections
852
+ const srcBase = path.join(tmpDir, theme.srcBase || "Orc");
853
+ let total = 0;
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;
193
869
  }
194
870
 
195
- const destFile = path.join(SOUNDS_DIR, category, file.name);
196
- if (fs.existsSync(srcFile)) {
197
- fs.copyFileSync(srcFile, destFile);
198
- } else {
199
- print(` Warning: ${file.src} not found, skipping`);
871
+ if (selectedIndices.includes(i)) {
872
+ fs.copyFileSync(srcFile, path.join(catDir, item.file));
873
+ total++;
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));
200
878
  }
879
+ // Unselected borrowed sounds: skip (no need to store)
201
880
  }
202
881
  }
203
882
 
204
- // 4. Install hooks
205
- print(" [4/4] Installing hooks...");
883
+ // Write install marker
884
+ writeInstalled(selectedTheme.name);
206
885
 
207
- // Copy play-sound.sh
886
+ // Copy play-sound.sh hook
208
887
  const hookSrc = path.join(PKG_DIR, "hooks", "play-sound.sh");
209
888
  const hookDest = path.join(HOOKS_DIR, "play-sound.sh");
210
889
  fs.copyFileSync(hookSrc, hookDest);
@@ -217,14 +896,12 @@ function install(themeName) {
217
896
 
218
897
  // Summary
219
898
  print("");
220
- print(" Installed! Here's what you'll hear:");
899
+ print(` ${GREEN}✓${RESET} Installed! Here's what you'll hear:`);
221
900
  print(" ─────────────────────────────────────");
222
901
 
223
- let total = 0;
224
- for (const [cat, config] of Object.entries(theme.sounds)) {
225
- const count = config.files.length;
226
- total += count;
227
- print(` ${cat} (${count}) — ${config.description}`);
902
+ for (const cat of categories) {
903
+ const count = selections[cat].length;
904
+ print(` ${cat} (${count}) ${theme.sounds[cat].description}`);
228
905
  }
229
906
 
230
907
  print("");
@@ -234,7 +911,7 @@ function install(themeName) {
234
911
  print(" Zug zug.");
235
912
  print("");
236
913
  } finally {
237
- // Cleanup
914
+ killPreview();
238
915
  fs.rmSync(tmpDir, { recursive: true, force: true });
239
916
  }
240
917
  }
@@ -242,21 +919,20 @@ function install(themeName) {
242
919
  // ─── Main ────────────────────────────────────────────────────────────────────
243
920
 
244
921
  const args = process.argv.slice(2);
245
- const arg = args[0] || "wc3-peon";
246
-
247
- switch (arg) {
248
- case "--help":
249
- case "-h":
250
- showHelp();
251
- break;
252
- case "--list":
253
- case "-l":
254
- showList();
255
- break;
256
- case "--uninstall":
257
- case "--remove":
258
- uninstall();
259
- break;
260
- default:
261
- install(arg);
922
+ const flags = new Set(args);
923
+ const autoYes = flags.has("--yes") || flags.has("-y");
924
+
925
+ // Handle non-interactive commands first
926
+ if (flags.has("--help") || flags.has("-h")) {
927
+ showHelp();
928
+ } else if (flags.has("--list") || flags.has("-l")) {
929
+ showList();
930
+ } else if (flags.has("--uninstall") || flags.has("--remove")) {
931
+ uninstall();
932
+ } else {
933
+ interactiveInstall(autoYes).catch((err) => {
934
+ killPreview();
935
+ process.stdout.write(SHOW_CURSOR);
936
+ die(err.message);
937
+ });
262
938
  }