claude-code-sounds 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/bin/cli.js +718 -705
  2. package/package.json +5 -1
  3. package/themes/mario/sounds/1-up.wav +0 -0
  4. package/themes/mario/sounds/block-bump.wav +0 -0
  5. package/themes/mario/sounds/break-block.wav +0 -0
  6. package/themes/mario/sounds/burned.wav +0 -0
  7. package/themes/mario/sounds/coin.wav +0 -0
  8. package/themes/mario/sounds/death.wav +0 -0
  9. package/themes/mario/sounds/doh.wav +0 -0
  10. package/themes/mario/sounds/fireball.wav +0 -0
  11. package/themes/mario/sounds/flagpole.wav +0 -0
  12. package/themes/mario/sounds/game-over.wav +0 -0
  13. package/themes/mario/sounds/haha.wav +0 -0
  14. package/themes/mario/sounds/happy-message.wav +0 -0
  15. package/themes/mario/sounds/hello.wav +0 -0
  16. package/themes/mario/sounds/here-we-go.wav +0 -0
  17. package/themes/mario/sounds/hurt.wav +0 -0
  18. package/themes/mario/sounds/its-a-me-mario.wav +0 -0
  19. package/themes/mario/sounds/lets-a-go.wav +0 -0
  20. package/themes/mario/sounds/level-clear.wav +0 -0
  21. package/themes/mario/sounds/mamma-mia.wav +0 -0
  22. package/themes/mario/sounds/message-block.wav +0 -0
  23. package/themes/mario/sounds/mushroom-appears.wav +0 -0
  24. package/themes/mario/sounds/okey-dokey.wav +0 -0
  25. package/themes/mario/sounds/oof.wav +0 -0
  26. package/themes/mario/sounds/pause.wav +0 -0
  27. package/themes/mario/sounds/pipe-warp.wav +0 -0
  28. package/themes/mario/sounds/power-up.wav +0 -0
  29. package/themes/mario/sounds/question-block.wav +0 -0
  30. package/themes/mario/sounds/raccoon-transform.wav +0 -0
  31. package/themes/mario/sounds/shrink.wav +0 -0
  32. package/themes/mario/sounds/smb3-power-up.wav +0 -0
  33. package/themes/mario/sounds/smw-1-up.wav +0 -0
  34. package/themes/mario/sounds/smw-pipe.wav +0 -0
  35. package/themes/mario/sounds/smw-power-up.wav +0 -0
  36. package/themes/mario/sounds/snore.wav +0 -0
  37. package/themes/mario/sounds/stage-clear.wav +0 -0
  38. package/themes/mario/sounds/star-appears.wav +0 -0
  39. package/themes/mario/sounds/stomp.wav +0 -0
  40. package/themes/mario/sounds/tired.wav +0 -0
  41. package/themes/mario/sounds/vine-grow.wav +0 -0
  42. package/themes/mario/sounds/waha.wav +0 -0
  43. package/themes/mario/sounds/warp-whistle.wav +0 -0
  44. package/themes/mario/sounds/warp.wav +0 -0
  45. package/themes/mario/sounds/whoa.wav +0 -0
  46. package/themes/mario/sounds/world-clear.wav +0 -0
  47. package/themes/mario/sounds/yahoo.wav +0 -0
  48. package/themes/mario/sounds/yawn.wav +0 -0
  49. package/themes/mario/sounds/yippee.wav +0 -0
  50. package/themes/mario/theme.json +144 -48
  51. package/themes/mgs/sounds/alert-mode.mp3 +0 -0
  52. package/themes/mgs/sounds/alert-sfx.mp3 +0 -0
  53. package/themes/mgs/sounds/alert.mp3 +0 -0
  54. package/themes/mgs/sounds/cigar.mp3 +0 -0
  55. package/themes/mgs/sounds/codec-beep.mp3 +0 -0
  56. package/themes/mgs/sounds/codec-call-out.mp3 +0 -0
  57. package/themes/mgs/sounds/codec-call.mp3 +0 -0
  58. package/themes/mgs/sounds/codec-close.mp3 +0 -0
  59. package/themes/mgs/sounds/codec-dial.mp3 +0 -0
  60. package/themes/mgs/sounds/codec-exit.mp3 +0 -0
  61. package/themes/mgs/sounds/codec-hangup.mp3 +0 -0
  62. package/themes/mgs/sounds/codec-ring.mp3 +0 -0
  63. package/themes/mgs/sounds/friendly-fire.mp3 +0 -0
  64. package/themes/mgs/sounds/game-over-fade.mp3 +0 -0
  65. package/themes/mgs/sounds/game-over-screen.mp3 +0 -0
  66. package/themes/mgs/sounds/game-over.mp3 +0 -0
  67. package/themes/mgs/sounds/good-shooting.mp3 +0 -0
  68. package/themes/mgs/sounds/if-you-say-so.mp3 +0 -0
  69. package/themes/mgs/sounds/ill-do-my-best.mp3 +0 -0
  70. package/themes/mgs/sounds/item-drop.mp3 +0 -0
  71. package/themes/mgs/sounds/just-to-suffer.mp3 +0 -0
  72. package/themes/mgs/sounds/kept-you-waiting-huh.mp3 +0 -0
  73. package/themes/mgs/sounds/kept-you-waiting.mp3 +0 -0
  74. package/themes/mgs/sounds/mission-complete.mp3 +0 -0
  75. package/themes/mgs/sounds/mission-qualify.mp3 +0 -0
  76. package/themes/mgs/sounds/ocelot-meow.mp3 +0 -0
  77. package/themes/mgs/sounds/original-game-over.mp3 +0 -0
  78. package/themes/mgs/sounds/roger-that.mp3 +0 -0
  79. package/themes/mgs/sounds/snake-dies.mp3 +0 -0
  80. package/themes/mgs/sounds/snake-scream.mp3 +0 -0
  81. package/themes/mgs/sounds/sounds-like-a-plan.mp3 +0 -0
  82. package/themes/mgs/sounds/sweet-dreams.mp3 +0 -0
  83. package/themes/mgs/sounds/this-is-snake.mp3 +0 -0
  84. package/themes/mgs/sounds/what-the-hell.mp3 +0 -0
  85. package/themes/mgs/sounds/what-was-that-noise.mp3 +0 -0
  86. package/themes/mgs/sounds/what-was-that.mp3 +0 -0
  87. package/themes/mgs/sounds/youre-pretty-good.mp3 +0 -0
  88. package/themes/mgs/theme.json +123 -41
  89. package/themes/pokemon-gen1/sounds/ball-poof.wav +0 -0
  90. package/themes/pokemon-gen1/sounds/ball-toss.wav +0 -0
  91. package/themes/pokemon-gen1/sounds/caught-mon.wav +0 -0
  92. package/themes/pokemon-gen1/sounds/charizard-cry.wav +0 -0
  93. package/themes/pokemon-gen1/sounds/collision.wav +0 -0
  94. package/themes/pokemon-gen1/sounds/confused.wav +0 -0
  95. package/themes/pokemon-gen1/sounds/denied.wav +0 -0
  96. package/themes/pokemon-gen1/sounds/dex-page-added.wav +0 -0
  97. package/themes/pokemon-gen1/sounds/enter-pc.wav +0 -0
  98. package/themes/pokemon-gen1/sounds/faint-fall.wav +0 -0
  99. package/themes/pokemon-gen1/sounds/faint-thud.wav +0 -0
  100. package/themes/pokemon-gen1/sounds/get-item-fanfare.wav +0 -0
  101. package/themes/pokemon-gen1/sounds/get-item.wav +0 -0
  102. package/themes/pokemon-gen1/sounds/get-key-item.wav +0 -0
  103. package/themes/pokemon-gen1/sounds/go-outside.wav +0 -0
  104. package/themes/pokemon-gen1/sounds/heal-ailment.wav +0 -0
  105. package/themes/pokemon-gen1/sounds/heal-up.wav +0 -0
  106. package/themes/pokemon-gen1/sounds/intro-lunge.wav +0 -0
  107. package/themes/pokemon-gen1/sounds/intro-whoosh.wav +0 -0
  108. package/themes/pokemon-gen1/sounds/jigglypuff-cry.wav +0 -0
  109. package/themes/pokemon-gen1/sounds/ledge.wav +0 -0
  110. package/themes/pokemon-gen1/sounds/level-up.wav +0 -0
  111. package/themes/pokemon-gen1/sounds/pikachu-cry.wav +0 -0
  112. package/themes/pokemon-gen1/sounds/poisoned.wav +0 -0
  113. package/themes/pokemon-gen1/sounds/pokeball-open.wav +0 -0
  114. package/themes/pokemon-gen1/sounds/pokeball-throw.wav +0 -0
  115. package/themes/pokemon-gen1/sounds/pokedex-rating.wav +0 -0
  116. package/themes/pokemon-gen1/sounds/pokemon-switch.wav +0 -0
  117. package/themes/pokemon-gen1/sounds/press-ab.wav +0 -0
  118. package/themes/pokemon-gen1/sounds/psyduck-cry.wav +0 -0
  119. package/themes/pokemon-gen1/sounds/purchase.wav +0 -0
  120. package/themes/pokemon-gen1/sounds/rest.wav +0 -0
  121. package/themes/pokemon-gen1/sounds/save-game.wav +0 -0
  122. package/themes/pokemon-gen1/sounds/screech.wav +0 -0
  123. package/themes/pokemon-gen1/sounds/self-destruct.wav +0 -0
  124. package/themes/pokemon-gen1/sounds/shrink.wav +0 -0
  125. package/themes/pokemon-gen1/sounds/silph-scope.wav +0 -0
  126. package/themes/pokemon-gen1/sounds/slots-new-spin.wav +0 -0
  127. package/themes/pokemon-gen1/sounds/slots-stop.wav +0 -0
  128. package/themes/pokemon-gen1/sounds/splash.wav +0 -0
  129. package/themes/pokemon-gen1/sounds/start-menu.wav +0 -0
  130. package/themes/pokemon-gen1/sounds/substitute.wav +0 -0
  131. package/themes/pokemon-gen1/sounds/swap.wav +0 -0
  132. package/themes/pokemon-gen1/sounds/teleport.wav +0 -0
  133. package/themes/pokemon-gen1/sounds/tink.wav +0 -0
  134. package/themes/pokemon-gen1/sounds/trade-machine.wav +0 -0
  135. package/themes/pokemon-gen1/sounds/turn-off-pc.wav +0 -0
  136. package/themes/pokemon-gen1/sounds/withdraw-deposit.wav +0 -0
  137. package/themes/pokemon-gen1/theme.json +150 -50
  138. package/themes/portal/sounds/announcer-post.wav +0 -0
  139. package/themes/portal/sounds/button-positive.wav +0 -0
  140. package/themes/portal/sounds/button-press.wav +0 -0
  141. package/themes/portal/sounds/button-release.wav +0 -0
  142. package/themes/portal/sounds/core-attach.wav +0 -0
  143. package/themes/portal/sounds/core-complete.wav +0 -0
  144. package/themes/portal/sounds/emancipation-grill.wav +0 -0
  145. package/themes/portal/sounds/fizzler-shutdown.wav +0 -0
  146. package/themes/portal/sounds/fizzler-start.wav +0 -0
  147. package/themes/portal/sounds/invalid-surface.wav +0 -0
  148. package/themes/portal/sounds/klaxon-alarm.wav +0 -0
  149. package/themes/portal/sounds/portal-close-alt.wav +0 -0
  150. package/themes/portal/sounds/portal-close.wav +0 -0
  151. package/themes/portal/sounds/portal-enter.wav +0 -0
  152. package/themes/portal/sounds/portal-exit.wav +0 -0
  153. package/themes/portal/sounds/portal-fizzle.wav +0 -0
  154. package/themes/portal/sounds/portal-open.wav +0 -0
  155. package/themes/portal/sounds/portal-whoosh-close.wav +0 -0
  156. package/themes/portal/sounds/portalgun-powerup.wav +0 -0
  157. package/themes/portal/sounds/shoot-blue-portal.wav +0 -0
  158. package/themes/portal/sounds/shoot-orange-portal.wav +0 -0
  159. package/themes/portal/sounds/synth-negative.wav +0 -0
  160. package/themes/portal/sounds/synth-positive.wav +0 -0
  161. package/themes/portal/sounds/test-chamber-complete.wav +0 -0
  162. package/themes/portal/sounds/test-chamber-start.wav +0 -0
  163. package/themes/portal/sounds/turret-activated.wav +0 -0
  164. package/themes/portal/sounds/turret-alert.wav +0 -0
  165. package/themes/portal/sounds/turret-are-you-still-there.wav +0 -0
  166. package/themes/portal/sounds/turret-deploy.wav +0 -0
  167. package/themes/portal/sounds/turret-done.wav +0 -0
  168. package/themes/portal/sounds/turret-goodbye.wav +0 -0
  169. package/themes/portal/sounds/turret-hello.wav +0 -0
  170. package/themes/portal/sounds/turret-hellooo.wav +0 -0
  171. package/themes/portal/sounds/turret-i-see-you.wav +0 -0
  172. package/themes/portal/sounds/turret-no-hard-feelings.wav +0 -0
  173. package/themes/portal/sounds/turret-ow.wav +0 -0
  174. package/themes/portal/sounds/turret-ping.wav +0 -0
  175. package/themes/portal/sounds/turret-retract.wav +0 -0
  176. package/themes/portal/sounds/turret-searching.wav +0 -0
  177. package/themes/portal/sounds/turret-target-lost.wav +0 -0
  178. package/themes/portal/sounds/turret-whos-there.wav +0 -0
  179. package/themes/portal/sounds/ui-click.wav +0 -0
  180. package/themes/portal/theme.json +129 -43
  181. package/themes/star-wars/sounds/bad-feeling.wav +0 -0
  182. package/themes/star-wars/sounds/blaster-firing.wav +0 -0
  183. package/themes/star-wars/sounds/chewie-chatting.wav +0 -0
  184. package/themes/star-wars/sounds/chewie-roar.wav +0 -0
  185. package/themes/star-wars/sounds/dark-side.wav +0 -0
  186. package/themes/star-wars/sounds/darth-maul-reveal-ourselves.wav +0 -0
  187. package/themes/star-wars/sounds/decided-to-rescue-you.wav +0 -0
  188. package/themes/star-wars/sounds/destiny-fulfilled.wav +0 -0
  189. package/themes/star-wars/sounds/destroy-you.wav +0 -0
  190. package/themes/star-wars/sounds/force-is-strong-with-this-one.wav +0 -0
  191. package/themes/star-wars/sounds/force-is-strong.wav +0 -0
  192. package/themes/star-wars/sounds/force-will-be-with-you.wav +0 -0
  193. package/themes/star-wars/sounds/great-disturbance.wav +0 -0
  194. package/themes/star-wars/sounds/i-am-your-father.wav +0 -0
  195. package/themes/star-wars/sounds/its-a-trap.wav +0 -0
  196. package/themes/star-wars/sounds/jabba-laughing.wav +0 -0
  197. package/themes/star-wars/sounds/lightsaber-ignite.wav +0 -0
  198. package/themes/star-wars/sounds/lightsaber-off.wav +0 -0
  199. package/themes/star-wars/sounds/lord-vader-rise.wav +0 -0
  200. package/themes/star-wars/sounds/luke-dont-do-that.wav +0 -0
  201. package/themes/star-wars/sounds/r2d2-beep.wav +0 -0
  202. package/themes/star-wars/sounds/r2d2-hey-you.wav +0 -0
  203. package/themes/star-wars/sounds/r2d2-woo-hoo.wav +0 -0
  204. package/themes/star-wars/sounds/r2d2-yeah.wav +0 -0
  205. package/themes/star-wars/sounds/vader-breathing.wav +0 -0
  206. package/themes/star-wars/sounds/vader-what-is-thy-bidding.wav +0 -0
  207. package/themes/star-wars/sounds/vader-yes-my-master.wav +0 -0
  208. package/themes/star-wars/sounds/wilhelm-scream.wav +0 -0
  209. package/themes/star-wars/sounds/yoda-900-years-old.wav +0 -0
  210. package/themes/star-wars/sounds/yoda-always-two.wav +0 -0
  211. package/themes/star-wars/sounds/yoda-dangerous-disturbing.wav +0 -0
  212. package/themes/star-wars/sounds/yoda-do-or-do-not.wav +0 -0
  213. package/themes/star-wars/sounds/yoda-laughing.wav +0 -0
  214. package/themes/star-wars/sounds/yoda-much-fear.wav +0 -0
  215. package/themes/star-wars/sounds/yoda-twisted-by-dark-side.wav +0 -0
  216. package/themes/star-wars/sounds/you-were-the-chosen-one.wav +0 -0
  217. package/themes/star-wars/theme.json +112 -37
  218. package/themes/wc3-peon/sounds/anything-you-want.wav +0 -0
  219. package/themes/wc3-peon/sounds/be-happy-to.wav +0 -0
  220. package/themes/wc3-peon/sounds/concentrate-and-ask-again.wav +0 -0
  221. package/themes/wc3-peon/sounds/dabu.wav +0 -0
  222. package/themes/wc3-peon/sounds/death.wav +0 -0
  223. package/themes/wc3-peon/sounds/finally.wav +0 -0
  224. package/themes/wc3-peon/sounds/for-the-horde.wav +0 -0
  225. package/themes/wc3-peon/sounds/get-em.wav +0 -0
  226. package/themes/wc3-peon/sounds/grunt-death.wav +0 -0
  227. package/themes/wc3-peon/sounds/headhunter-death.wav +0 -0
  228. package/themes/wc3-peon/sounds/hmmm.wav +0 -0
  229. package/themes/wc3-peon/sounds/how-can-i-help.wav +0 -0
  230. package/themes/wc3-peon/sounds/i-can-do-that.wav +0 -0
  231. package/themes/wc3-peon/sounds/i-can-wait-no-longer.wav +0 -0
  232. package/themes/wc3-peon/sounds/ill-try.wav +0 -0
  233. package/themes/wc3-peon/sounds/immediately.wav +0 -0
  234. package/themes/wc3-peon/sounds/it-is-certain.wav +0 -0
  235. package/themes/wc3-peon/sounds/me-busy-leave-me-alone.wav +0 -0
  236. package/themes/wc3-peon/sounds/me-not-that-kind-of-orc.wav +0 -0
  237. package/themes/wc3-peon/sounds/more-work.mp3 +0 -0
  238. package/themes/wc3-peon/sounds/no-time-for-play.wav +0 -0
  239. package/themes/wc3-peon/sounds/not-easy-being-green.wav +0 -0
  240. package/themes/wc3-peon/sounds/of-course.wav +0 -0
  241. package/themes/wc3-peon/sounds/ok.wav +0 -0
  242. package/themes/wc3-peon/sounds/okie-dokie.wav +0 -0
  243. package/themes/wc3-peon/sounds/outlook-not-so-good.wav +0 -0
  244. package/themes/wc3-peon/sounds/peon-death.wav +0 -0
  245. package/themes/wc3-peon/sounds/ready-to-work.wav +0 -0
  246. package/themes/wc3-peon/sounds/reply-hazy-try-again.wav +0 -0
  247. package/themes/wc3-peon/sounds/right-away.wav +0 -0
  248. package/themes/wc3-peon/sounds/someone-call-for-the-doctor.wav +0 -0
  249. package/themes/wc3-peon/sounds/something-need-doing.wav +0 -0
  250. package/themes/wc3-peon/sounds/taste-the-fury.wav +0 -0
  251. package/themes/wc3-peon/sounds/understood.wav +0 -0
  252. package/themes/wc3-peon/sounds/well-done.wav +0 -0
  253. package/themes/wc3-peon/sounds/what-you-want-me-to-do.wav +0 -0
  254. package/themes/wc3-peon/sounds/what-you-want.wav +0 -0
  255. package/themes/wc3-peon/sounds/what.wav +0 -0
  256. package/themes/wc3-peon/sounds/whatever-you-say.wav +0 -0
  257. package/themes/wc3-peon/sounds/who-you-want-me-kill.wav +0 -0
  258. package/themes/wc3-peon/sounds/why-not.wav +0 -0
  259. package/themes/wc3-peon/sounds/why-you-poking-me.wav +0 -0
  260. package/themes/wc3-peon/sounds/work-work.wav +0 -0
  261. package/themes/wc3-peon/sounds/yes.wav +0 -0
  262. package/themes/wc3-peon/sounds/you-seek-me-help.wav +0 -0
  263. package/themes/wc3-peon/sounds/zug-zug.wav +0 -0
  264. package/themes/wc3-peon/theme.json +175 -58
  265. package/themes/zelda-oot/sounds/dialogue-done.wav +0 -0
  266. package/themes/zelda-oot/sounds/dialogue-next.wav +0 -0
  267. package/themes/zelda-oot/sounds/error.wav +0 -0
  268. package/themes/zelda-oot/sounds/ganondorf-laugh.wav +0 -0
  269. package/themes/zelda-oot/sounds/get-heart.wav +0 -0
  270. package/themes/zelda-oot/sounds/get-item.wav +0 -0
  271. package/themes/zelda-oot/sounds/get-rupee.wav +0 -0
  272. package/themes/zelda-oot/sounds/great-fairy-laugh.wav +0 -0
  273. package/themes/zelda-oot/sounds/hello.wav +0 -0
  274. package/themes/zelda-oot/sounds/hey.wav +0 -0
  275. package/themes/zelda-oot/sounds/item-fanfare.wav +0 -0
  276. package/themes/zelda-oot/sounds/link-attack.wav +0 -0
  277. package/themes/zelda-oot/sounds/link-hurt.wav +0 -0
  278. package/themes/zelda-oot/sounds/link-strong-attack.wav +0 -0
  279. package/themes/zelda-oot/sounds/listen.wav +0 -0
  280. package/themes/zelda-oot/sounds/look.wav +0 -0
  281. package/themes/zelda-oot/sounds/low-health.wav +0 -0
  282. package/themes/zelda-oot/sounds/menu-select.wav +0 -0
  283. package/themes/zelda-oot/sounds/open-chest.wav +0 -0
  284. package/themes/zelda-oot/sounds/open-small-chest.wav +0 -0
  285. package/themes/zelda-oot/sounds/pause-close.wav +0 -0
  286. package/themes/zelda-oot/sounds/pause-menu.wav +0 -0
  287. package/themes/zelda-oot/sounds/secret-discovered.wav +0 -0
  288. package/themes/zelda-oot/sounds/snore.wav +0 -0
  289. package/themes/zelda-oot/sounds/song-correct.wav +0 -0
  290. package/themes/zelda-oot/sounds/song-error.wav +0 -0
  291. package/themes/zelda-oot/sounds/spin-attack.wav +0 -0
  292. package/themes/zelda-oot/sounds/sword-draw.wav +0 -0
  293. package/themes/zelda-oot/sounds/watch-out.wav +0 -0
  294. package/themes/zelda-oot/sounds/z-target.wav +0 -0
  295. package/themes/zelda-oot/theme.json +144 -48
  296. package/themes/mario/download.sh +0 -123
  297. package/themes/mgs/download.sh +0 -74
  298. package/themes/pokemon-gen1/download.sh +0 -75
  299. package/themes/portal/download.sh +0 -95
  300. package/themes/star-wars/download.sh +0 -119
  301. package/themes/wc3-peon/download.sh +0 -31
  302. package/themes/zelda-oot/download.sh +0 -67
package/bin/cli.js CHANGED
@@ -3,8 +3,10 @@
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const os = require("os");
6
- const readline = require("readline");
7
6
  const { execSync, spawn } = require("child_process");
7
+ const p = require("@clack/prompts");
8
+ const { Prompt } = require("@clack/core");
9
+ const color = require("picocolors");
8
10
 
9
11
  // ─── Paths ───────────────────────────────────────────────────────────────────
10
12
 
@@ -18,15 +20,6 @@ const INSTALLED_PATH = path.join(SOUNDS_DIR, ".installed.json");
18
20
 
19
21
  // ─── Helpers ─────────────────────────────────────────────────────────────────
20
22
 
21
- function print(msg = "") {
22
- process.stdout.write(msg + "\n");
23
- }
24
-
25
- function die(msg) {
26
- console.error(`\n Error: ${msg}\n`);
27
- process.exit(1);
28
- }
29
-
30
23
  function mkdirp(dir) {
31
24
  fs.mkdirSync(dir, { recursive: true });
32
25
  }
@@ -35,13 +28,34 @@ function exec(cmd, opts = {}) {
35
28
  return execSync(cmd, { encoding: "utf-8", stdio: "pipe", ...opts });
36
29
  }
37
30
 
31
+ function hasCommand(name) {
32
+ try {
33
+ exec(`which ${name}`);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
38
40
  function listThemes() {
39
41
  const themes = [];
40
42
  for (const name of fs.readdirSync(THEMES_DIR)) {
41
43
  const themeJson = path.join(THEMES_DIR, name, "theme.json");
42
44
  if (!fs.existsSync(themeJson)) continue;
43
45
  const meta = JSON.parse(fs.readFileSync(themeJson, "utf-8"));
44
- themes.push({ name, description: meta.description || "", display: meta.name || name });
46
+ let soundCount = 0;
47
+ if (meta.sounds) {
48
+ for (const cat of Object.values(meta.sounds)) {
49
+ soundCount += cat.files.length;
50
+ }
51
+ }
52
+ themes.push({
53
+ name,
54
+ description: meta.description || "",
55
+ display: meta.name || name,
56
+ soundCount,
57
+ sources: meta.sources || [],
58
+ });
45
59
  }
46
60
  return themes;
47
61
  }
@@ -58,15 +72,6 @@ function writeSettings(settings) {
58
72
  fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
59
73
  }
60
74
 
61
- function hasCommand(name) {
62
- try {
63
- exec(`which ${name}`);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
75
  function readInstalled() {
71
76
  if (fs.existsSync(INSTALLED_PATH)) {
72
77
  return JSON.parse(fs.readFileSync(INSTALLED_PATH, "utf-8"));
@@ -74,72 +79,22 @@ function readInstalled() {
74
79
  return null;
75
80
  }
76
81
 
77
- function writeInstalled(themeName) {
82
+ function writeInstalled(data) {
78
83
  mkdirp(SOUNDS_DIR);
79
- fs.writeFileSync(INSTALLED_PATH, JSON.stringify({ theme: themeName }, null, 2) + "\n");
84
+ fs.writeFileSync(INSTALLED_PATH, JSON.stringify(data, null, 2) + "\n");
80
85
  }
81
86
 
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
- };
87
+ function readThemeJson(themeName) {
88
+ return JSON.parse(
89
+ fs.readFileSync(path.join(THEMES_DIR, themeName, "theme.json"), "utf-8")
90
+ );
115
91
  }
116
92
 
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);
93
+ function resolveThemeSoundPath(themeName, fileName) {
94
+ return path.join(THEMES_DIR, themeName, "sounds", fileName);
140
95
  }
141
96
 
142
- // ─── Interactive UI ──────────────────────────────────────────────────────────
97
+ // ─── Preview ─────────────────────────────────────────────────────────────────
143
98
 
144
99
  let previewProcess = null;
145
100
 
@@ -159,761 +114,818 @@ function playPreview(filePath) {
159
114
  }
160
115
  }
161
116
 
162
- function cleanupAndExit() {
163
- killPreview();
164
- process.stdout.write(SHOW_CURSOR);
165
- print("\n");
166
- process.exit(0);
167
- }
117
+ // ─── Hooks Config ────────────────────────────────────────────────────────────
118
+
119
+ const HOOKS_CONFIG = {
120
+ SessionStart: [{ matcher: "startup", hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" start', timeout: 5 }] }],
121
+ SessionEnd: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" end', timeout: 5 }] }],
122
+ Notification: [
123
+ { matcher: "permission_prompt", hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" permission', timeout: 5 }] },
124
+ { matcher: "idle_prompt", hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" idle', timeout: 5 }] },
125
+ ],
126
+ Stop: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" stop', timeout: 5 }] }],
127
+ SubagentStart: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" subagent', timeout: 5 }] }],
128
+ PostToolUseFailure: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" error', timeout: 5 }] }],
129
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" prompt', timeout: 5 }] }],
130
+ TaskCompleted: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" task-completed', timeout: 5 }] }],
131
+ PreCompact: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" compact', timeout: 5 }] }],
132
+ TeammateIdle: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" teammate-idle', timeout: 5 }] }],
133
+ };
134
+
135
+ // ─── Sound Grid Prompt ───────────────────────────────────────────────────────
136
+
137
+ const HOOKS = [
138
+ { key: "start", abbr: "str", description: "Session starting" },
139
+ { key: "prompt", abbr: "pmt", description: "User submitted prompt" },
140
+ { key: "permission", abbr: "prm", description: "Permission prompt" },
141
+ { key: "stop", abbr: "stp", description: "Done responding" },
142
+ { key: "subagent", abbr: "sub", description: "Spawning subagent" },
143
+ { key: "task-completed", abbr: "tsk", description: "Task finished" },
144
+ { key: "error", abbr: "err", description: "Tool failure" },
145
+ { key: "compact", abbr: "cmp", description: "Context compaction" },
146
+ { key: "idle", abbr: "idl", description: "Waiting for input" },
147
+ { key: "teammate-idle", abbr: "tmt", description: "Teammate went idle" },
148
+ { key: "end", abbr: "end", description: "Session over" },
149
+ ];
168
150
 
169
151
  /**
170
- * Single-select menu with arrow keys.
171
- * Returns the index of the chosen option.
152
+ * A 2D grid prompt for assigning sounds to hooks.
153
+ *
154
+ * Rows are sounds (grouped by theme with visual headers), columns are hooks.
155
+ * Navigate with arrow keys, toggle with space, preview with 'p', toggle
156
+ * entire column with 'a'.
157
+ *
158
+ * @param {object} opts
159
+ * @param {string} opts.message - Prompt title
160
+ * @param {Array<{type: 'header'|'sound', theme: string, label: string, fileName?: string, previewPath?: string}>} opts.rows
161
+ * @param {Array<{key: string, abbr: string, description: string}>} opts.hooks
162
+ * @param {boolean[][]} opts.initialGrid - [soundIndex][hookIndex]
172
163
  */
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}`);
164
+ class SoundGrid extends Prompt {
165
+ constructor({ message, rows, hooks, initialGrid }) {
166
+ const soundIndices = [];
167
+ for (let i = 0; i < rows.length; i++) {
168
+ if (rows[i].type === 'sound') soundIndices.push(i);
188
169
  }
189
170
 
190
- process.stdout.write(HIDE_CURSOR);
191
- render(true);
171
+ super({
172
+ render() {
173
+ const grid = this._grid;
174
+ const cursorRow = this._cursorRow;
175
+ const cursorCol = this._cursorCol;
176
+ let scrollTop = this._scrollTop;
177
+ const myRows = this._rows;
178
+ const myHooks = this._hooks;
179
+ const myMessage = this._message;
180
+ const mySoundIndices = this._soundIndices;
181
+
182
+ const termCols = process.stdout.columns || 80;
183
+ const termRows = process.stdout.rows || 24;
184
+ const lines = [];
185
+
186
+ if (this.state === 'submit') {
187
+ lines.push(`${color.gray(p.S_BAR)}`);
188
+ let totalSel = 0;
189
+ for (const r of grid) for (const c of r) if (c) totalSel++;
190
+ lines.push(`${color.green(p.S_STEP_SUBMIT)} ${myMessage} ${color.dim(`(${totalSel} assigned)`)}`);
191
+ return lines.join('\n');
192
+ }
192
193
 
193
- process.stdin.setRawMode(true);
194
- process.stdin.resume();
195
- process.stdin.setEncoding("utf-8");
194
+ if (this.state === 'cancel') {
195
+ lines.push(`${color.gray(p.S_BAR)}`);
196
+ lines.push(`${color.red(p.S_STEP_ACTIVE)} ${myMessage}`);
197
+ return lines.join('\n');
198
+ }
196
199
 
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
- }
200
+ // Active state
201
+ lines.push(`${color.gray(p.S_BAR)}`);
202
+ lines.push(`${color.cyan(p.S_STEP_ACTIVE)} ${myMessage}`);
203
+
204
+ // ── Layout calculation ──
205
+ // Line structure: "" (3) + cursor+space (2) + label (labelWidth-2) + [◂] + columns + [▸]
206
+ // Header uses: "│ " (3) + spaces (labelWidth) + [◂] + columns + [▸]
207
+ // Both total: 3 + labelWidth + margins + visibleCols*4
208
+ const totalHooks = myHooks.length;
209
+ const maxLabelWidth = 25;
210
+ const colWidth = 4;
211
+ const linePrefix = 3; // "│ "
212
+
213
+ // First try: all columns without scroll margins
214
+ let labelWidth = maxLabelWidth;
215
+ const noMarginCols = Math.floor((termCols - linePrefix - labelWidth) / colWidth);
216
+ let needsHScroll, visibleCols;
217
+
218
+ if (noMarginCols >= totalHooks) {
219
+ needsHScroll = false;
220
+ visibleCols = totalHooks;
221
+ } else {
222
+ // Need horizontal scroll — reserve 2 chars for ◂/▸ indicators
223
+ needsHScroll = true;
224
+ const withMarginCols = Math.floor((termCols - linePrefix - labelWidth - 2) / colWidth);
225
+ if (withMarginCols >= 1) {
226
+ visibleCols = withMarginCols;
227
+ } else {
228
+ // Very narrow — shrink label to fit at least 1 column
229
+ labelWidth = Math.max(8, termCols - linePrefix - 2 - colWidth);
230
+ visibleCols = 1;
231
+ }
232
+ }
206
233
 
207
- // Arrow up
208
- if (key === "\x1b[A" || key === "k") {
209
- cursor = (cursor - 1 + options.length) % options.length;
210
- render(false);
211
- return;
212
- }
234
+ const maxLabel = labelWidth - 2; // label text area after cursor+space
213
235
 
214
- // Arrow down
215
- if (key === "\x1b[B" || key === "j") {
216
- cursor = (cursor + 1) % options.length;
217
- render(false);
218
- return;
219
- }
236
+ // ── Horizontal scroll ──
237
+ let colStart = this._colStart || 0;
238
+ if (needsHScroll) {
239
+ if (cursorCol < colStart) colStart = cursorCol;
240
+ if (cursorCol >= colStart + visibleCols) colStart = cursorCol - visibleCols + 1;
241
+ colStart = Math.max(0, Math.min(colStart, totalHooks - visibleCols));
242
+ } else {
243
+ colStart = 0;
244
+ }
245
+ this._colStart = colStart;
246
+
247
+ const showLeftArrow = needsHScroll && colStart > 0;
248
+ const showRightArrow = needsHScroll && colStart + visibleCols < totalHooks;
249
+ const leftMargin = needsHScroll ? (showLeftArrow ? color.dim('\u25C2') : ' ') : '';
250
+ const rightMargin = needsHScroll ? (showRightArrow ? color.dim('\u25B8') : ' ') : '';
251
+
252
+ // ── Column header line ──
253
+ let headerLine = `${color.gray(p.S_BAR)} ${''.padEnd(labelWidth)}${leftMargin}`;
254
+ for (let c = colStart; c < colStart + visibleCols; c++) {
255
+ const abbr = myHooks[c].abbr.padStart(colWidth);
256
+ headerLine += c === cursorCol ? color.cyan(color.bold(abbr)) : color.dim(abbr);
257
+ }
258
+ headerLine += rightMargin;
259
+ lines.push(headerLine);
260
+
261
+ // ── Vertical scrolling ──
262
+ const reservedLines = 4;
263
+ const maxVisible = Math.max(5, termRows - lines.length - reservedLines - 2);
264
+ const currentRowIdx = mySoundIndices[cursorRow];
265
+
266
+ if (currentRowIdx < scrollTop) {
267
+ scrollTop = Math.max(0, currentRowIdx - 1);
268
+ } else if (currentRowIdx >= scrollTop + maxVisible) {
269
+ scrollTop = currentRowIdx - maxVisible + 1;
270
+ }
271
+ if (scrollTop > 0 && myRows[scrollTop]?.type === 'sound') {
272
+ for (let i = scrollTop - 1; i >= Math.max(0, scrollTop - 2); i--) {
273
+ if (myRows[i].type === 'header') {
274
+ scrollTop = i;
275
+ break;
276
+ }
277
+ }
278
+ }
220
279
 
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
- }
280
+ const showScrollUp = scrollTop > 0;
281
+ const showScrollDown = scrollTop + maxVisible < myRows.length;
235
282
 
236
- process.stdin.on("data", onKey);
237
- });
238
- }
283
+ if (showScrollUp) {
284
+ lines.push(`${color.gray(p.S_BAR)} ${color.dim(' \u25B2')}`);
285
+ }
239
286
 
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
- }
287
+ // ── Render rows ──
288
+ const contentWidth = needsHScroll
289
+ ? labelWidth + 2 + visibleCols * colWidth
290
+ : labelWidth + visibleCols * colWidth;
291
+
292
+ let visibleCount = 0;
293
+ for (let i = scrollTop; i < myRows.length && visibleCount < maxVisible; i++) {
294
+ const row = myRows[i];
295
+ if (row.type === 'header') {
296
+ const hdr = `\u2500\u2500 ${row.label} `;
297
+ const dashLen = Math.max(2, contentWidth - hdr.length);
298
+ lines.push(`${color.gray(p.S_BAR)} ${color.gray(hdr + '\u2500'.repeat(dashLen))}`);
299
+ } else {
300
+ const soundIdx = mySoundIndices.indexOf(i);
301
+ const isActiveRow = soundIdx === cursorRow;
302
+ const pointer = isActiveRow ? color.cyan('\u203A') : ' ';
303
+ const rawLabel = row.label.length > maxLabel
304
+ ? row.label.substring(0, maxLabel - 1) + '\u2026'
305
+ : row.label;
306
+ const paddedLabel = rawLabel.padEnd(maxLabel);
307
+ const styledLabel = isActiveRow
308
+ ? color.white(paddedLabel)
309
+ : color.dim(paddedLabel);
310
+
311
+ let cellsStr = leftMargin;
312
+ for (let c = colStart; c < colStart + visibleCols; c++) {
313
+ const isActive = isActiveRow && c === cursorCol;
314
+ const isChecked = grid[soundIdx][c];
315
+ const cell = isChecked ? ' [x]' : ' [ ]';
316
+
317
+ if (isActive) {
318
+ cellsStr += color.cyan(color.bold(cell));
319
+ } else if (isChecked) {
320
+ cellsStr += color.green(cell);
321
+ } else {
322
+ cellsStr += color.dim(cell);
323
+ }
324
+ }
325
+ cellsStr += rightMargin;
263
326
 
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}`);
327
+ lines.push(`${color.gray(p.S_BAR)} ${pointer} ${styledLabel}${cellsStr}`);
328
+ }
329
+ visibleCount++;
287
330
  }
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
331
 
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
- }
332
+ if (showScrollDown) {
333
+ lines.push(`${color.gray(p.S_BAR)} ${color.dim(' \u25BC')}`);
334
+ }
312
335
 
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;
336
+ // ── Footer ──
337
+ lines.push(`${color.gray(p.S_BAR)}`);
338
+ const helpFull = '\u2191\u2193 sounds \u00B7 \u2190\u2192 hooks \u00B7 space toggle \u00B7 p preview \u00B7 a column all \u00B7 enter done';
339
+ const helpShort = '\u2191\u2193/\u2190\u2192 move \u00B7 space \u00B7 p play \u00B7 a all \u00B7 enter';
340
+ const helpText = (helpFull.length + 5 <= termCols) ? helpFull : helpShort;
341
+ lines.push(`${color.gray(p.S_BAR)} ${color.dim(helpText)}`);
342
+
343
+ const hook = myHooks[cursorCol];
344
+ const hookLine = needsHScroll
345
+ ? `${color.dim('Hook:')} ${color.cyan(hook.key)} ${color.dim('\u2014')} ${color.dim(hook.description)} ${color.dim(`(${colStart + 1}\u2013${colStart + visibleCols} of ${totalHooks})`)}`
346
+ : `${color.dim('Hook:')} ${color.cyan(hook.key)} ${color.dim('\u2014')} ${color.dim(hook.description)}`;
347
+ lines.push(`${color.gray(p.S_BAR)} ${hookLine}`);
348
+
349
+ this._scrollTop = scrollTop;
350
+ return lines.join('\n');
351
+ },
352
+ }, false);
353
+
354
+ this._rows = rows;
355
+ this._hooks = hooks;
356
+ this._soundIndices = soundIndices;
357
+ this._message = message;
358
+ this._cursorRow = 0;
359
+ this._cursorCol = 0;
360
+ this._grid = initialGrid.map(r => [...r]);
361
+ this._scrollTop = 0;
362
+ this._colStart = 0;
363
+
364
+ this.on('cursor', (action) => {
365
+ if (action === 'up') {
366
+ if (this._soundIndices.length > 0) {
367
+ this._cursorRow = (this._cursorRow - 1 + this._soundIndices.length) % this._soundIndices.length;
368
+ const rowIdx = this._soundIndices[this._cursorRow];
369
+ const row = this._rows[rowIdx];
370
+ if (row?.previewPath) playPreview(row.previewPath);
371
+ }
372
+ } else if (action === 'down') {
373
+ if (this._soundIndices.length > 0) {
374
+ this._cursorRow = (this._cursorRow + 1) % this._soundIndices.length;
375
+ const rowIdx = this._soundIndices[this._cursorRow];
376
+ const row = this._rows[rowIdx];
377
+ if (row?.previewPath) playPreview(row.previewPath);
378
+ }
379
+ } else if (action === 'left') {
380
+ this._cursorCol = (this._cursorCol - 1 + this._hooks.length) % this._hooks.length;
381
+ } else if (action === 'right') {
382
+ this._cursorCol = (this._cursorCol + 1) % this._hooks.length;
383
+ } else if (action === 'space') {
384
+ this._grid[this._cursorRow][this._cursorCol] = !this._grid[this._cursorRow][this._cursorCol];
324
385
  }
386
+ });
325
387
 
326
- if (key === "\x1b[A" || key === "k") {
327
- cursor = (cursor - 1 + items.length) % items.length;
328
- adjustScroll();
329
- render(false);
330
- return;
388
+ this.on('key', (char) => {
389
+ if (char === 'p') {
390
+ const rowIdx = this._soundIndices[this._cursorRow];
391
+ const row = this._rows[rowIdx];
392
+ if (row && row.previewPath) {
393
+ playPreview(row.previewPath);
394
+ }
331
395
  }
332
-
333
- if (key === "\x1b[B" || key === "j") {
334
- cursor = (cursor + 1) % items.length;
335
- adjustScroll();
336
- render(false);
337
- return;
396
+ if (char === 'a') {
397
+ const col = this._cursorCol;
398
+ const allChecked = this._grid.every(r => r[col]);
399
+ for (let i = 0; i < this._grid.length; i++) {
400
+ this._grid[i][col] = !allChecked;
401
+ }
338
402
  }
403
+ });
339
404
 
340
- // Space toggle
341
- if (key === " ") {
342
- checked[cursor] = !checked[cursor];
343
- render(false);
344
- return;
405
+ this.on('finalize', () => {
406
+ if (this.state === 'submit') {
407
+ const result = {};
408
+ for (let c = 0; c < this._hooks.length; c++) {
409
+ const hookKey = this._hooks[c].key;
410
+ result[hookKey] = [];
411
+ for (let r = 0; r < this._soundIndices.length; r++) {
412
+ if (this._grid[r][c]) {
413
+ const rowIdx = this._soundIndices[r];
414
+ const row = this._rows[rowIdx];
415
+ result[hookKey].push({ theme: row.theme, fileName: row.fileName });
416
+ }
417
+ }
418
+ }
419
+ this.value = result;
345
420
  }
421
+ });
422
+ }
423
+ }
346
424
 
347
- // a toggle all
348
- if (key === "a") {
349
- const allChecked = checked.every(Boolean);
350
- checked.fill(!allChecked);
351
- render(false);
352
- return;
353
- }
425
+ // ─── Install Sounds ──────────────────────────────────────────────────────────
354
426
 
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
- }
427
+ /**
428
+ * Copy selected sound files from package to SOUNDS_DIR.
429
+ *
430
+ * @param {Object<string, Array<{themeName: string, fileName: string}>>} selections
431
+ * @returns {number} Total files installed
432
+ */
433
+ function installSounds(selections) {
434
+ let total = 0;
361
435
 
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();
436
+ for (const [cat, items] of Object.entries(selections)) {
437
+ const catDir = path.join(SOUNDS_DIR, cat);
438
+ mkdirp(catDir);
368
439
 
369
- const selected = [];
370
- for (let i = 0; i < checked.length; i++) {
371
- if (checked[i]) selected.push(i);
440
+ // Clear existing sounds in this category
441
+ try {
442
+ for (const f of fs.readdirSync(catDir)) {
443
+ if (f.endsWith(".wav") || f.endsWith(".mp3")) {
444
+ fs.unlinkSync(path.join(catDir, f));
372
445
  }
446
+ }
447
+ } catch {}
373
448
 
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;
449
+ // Copy selected sounds
450
+ for (const item of items) {
451
+ const srcPath = resolveThemeSoundPath(item.themeName, item.fileName);
452
+ const destPath = path.join(catDir, item.fileName);
453
+
454
+ if (fs.existsSync(srcPath)) {
455
+ fs.copyFileSync(srcPath, destPath);
456
+ total++;
382
457
  }
383
458
  }
459
+ }
384
460
 
385
- process.stdin.on("data", onKey);
386
- });
461
+ return total;
387
462
  }
388
463
 
389
464
  /**
390
- * Y/n confirmation prompt.
465
+ * Write hooks config and copy play-sound.sh script.
391
466
  */
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
- });
467
+ function installHooksConfig() {
468
+ mkdirp(HOOKS_DIR);
469
+
470
+ const hookSrc = path.join(PKG_DIR, "hooks", "play-sound.sh");
471
+ const hookDest = path.join(HOOKS_DIR, "play-sound.sh");
472
+ fs.copyFileSync(hookSrc, hookDest);
473
+ fs.chmodSync(hookDest, 0o755);
474
+
475
+ const settings = readSettings();
476
+ settings.hooks = HOOKS_CONFIG;
477
+ writeSettings(settings);
403
478
  }
404
479
 
405
- // ─── Hooks Config ────────────────────────────────────────────────────────────
480
+ // ─── Print Summary ───────────────────────────────────────────────────────────
406
481
 
407
- const HOOKS_CONFIG = {
408
- SessionStart: [{ matcher: "startup", hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" start', timeout: 5 }] }],
409
- SessionEnd: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" end', timeout: 5 }] }],
410
- Notification: [
411
- { matcher: "permission_prompt", hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" permission', timeout: 5 }] },
412
- { matcher: "idle_prompt", hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" idle', timeout: 5 }] },
413
- ],
414
- Stop: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" stop', timeout: 5 }] }],
415
- SubagentStart: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" subagent', timeout: 5 }] }],
416
- PostToolUseFailure: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" error', timeout: 5 }] }],
417
- UserPromptSubmit: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" prompt', timeout: 5 }] }],
418
- TaskCompleted: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" task-completed', timeout: 5 }] }],
419
- PreCompact: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" compact', timeout: 5 }] }],
420
- TeammateIdle: [{ hooks: [{ type: "command", command: '/bin/bash "$HOME/.claude/hooks/play-sound.sh" teammate-idle', timeout: 5 }] }],
421
- };
482
+ function printSummary(selections) {
483
+ const cats = Object.keys(selections);
484
+ let total = 0;
485
+
486
+ for (const cat of cats) {
487
+ const count = selections[cat].length;
488
+ total += count;
489
+ p.log.step(`${cat} (${count})`);
490
+ }
422
491
 
423
- // ─── Non-Interactive Commands ───────────────────────────────────────────────
492
+ p.log.success(`Installed ${total} sounds across ${cats.length} hooks.`);
493
+ }
494
+
495
+ // ─── Non-Interactive Commands ────────────────────────────────────────────────
424
496
 
425
497
  function showHelp() {
426
- print("");
427
- print(" claude-code-sounds");
428
- print(" ──────────────────────────────");
429
- print("");
430
- print(" Usage:");
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");
441
- print("");
498
+ console.log(`
499
+ claude-code-sounds
500
+ ──────────────────────────────
501
+
502
+ Usage:
503
+ npx claude-code-sounds Interactive install
504
+ npx claude-code-sounds --yes Install defaults, skip prompts
505
+ npx claude-code-sounds --list List available themes
506
+ npx claude-code-sounds --uninstall Remove all sounds and hooks
507
+ npx claude-code-sounds --help Show this help
508
+
509
+ Flags:
510
+ -y, --yes Skip all prompts, use defaults
511
+ -l, --list List available themes
512
+ -h, --help Show this help
513
+ `);
442
514
  }
443
515
 
444
516
  function showList() {
445
- print("");
446
- print(" Available themes:");
447
- print("");
448
- for (const t of listThemes()) {
449
- print(` ${t.name} — ${t.description}`);
517
+ const themes = listThemes();
518
+ console.log("\n Available themes:\n");
519
+ for (const t of themes) {
520
+ const src = t.sources.length > 0 ? ` [${t.sources.join(", ")}]` : "";
521
+ console.log(` ${t.name} — ${t.description} (${t.soundCount} sounds)${src}`);
450
522
  }
451
- print("");
523
+ console.log();
452
524
  }
453
525
 
454
- function uninstall() {
455
- print("");
456
- print(" Uninstalling claude-code-sounds...");
457
-
526
+ function uninstallAll() {
458
527
  if (fs.existsSync(SOUNDS_DIR)) {
459
528
  fs.rmSync(SOUNDS_DIR, { recursive: true });
460
- print(" Removed ~/.claude/sounds/");
529
+ p.log.step("Removed ~/.claude/sounds/");
461
530
  }
462
531
 
463
532
  const hookScript = path.join(HOOKS_DIR, "play-sound.sh");
464
533
  if (fs.existsSync(hookScript)) {
465
534
  fs.unlinkSync(hookScript);
466
- print(" Removed ~/.claude/hooks/play-sound.sh");
535
+ p.log.step("Removed ~/.claude/hooks/play-sound.sh");
467
536
  }
468
537
 
469
538
  if (fs.existsSync(SETTINGS_PATH)) {
470
539
  const settings = readSettings();
471
540
  delete settings.hooks;
472
541
  writeSettings(settings);
473
- print(" Removed hooks from settings.json");
542
+ p.log.step("Removed hooks from settings.json");
474
543
  }
475
-
476
- print("");
477
- print(" Done. All sounds removed.");
478
- print("");
479
544
  }
480
545
 
481
- // ─── Sound Item Builder ─────────────────────────────────────────────────────
546
+ // ─── Check Dependencies ─────────────────────────────────────────────────────
482
547
 
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
- }
548
+ function checkDependencies() {
549
+ if (!hasCommand("afplay")) {
550
+ p.cancel("afplay is not available. claude-code-sounds requires macOS.");
551
+ process.exit(1);
501
552
  }
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
553
  }
535
554
 
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
- }
555
+ // ─── Detect Existing Install ─────────────────────────────────────────────────
545
556
 
546
- // ─── Reconfigure Flow ───────────────────────────────────────────────────────
557
+ function detectExistingInstall() {
558
+ const installed = readInstalled();
559
+ if (!installed) return null;
547
560
 
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 = [];
561
+ // Support both old format { theme: "name" } and new { themes: [...], mode }
562
+ const themeNames = installed.themes || (installed.theme ? [installed.theme] : []);
563
+ if (themeNames.length === 0) return null;
553
564
 
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
- }
565
+ // Count enabled sounds across all categories
566
+ let totalEnabled = 0;
567
+ const allCategories = new Set();
600
568
 
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
- }
569
+ for (const themeName of themeNames) {
570
+ try {
571
+ const theme = readThemeJson(themeName);
572
+ for (const cat of Object.keys(theme.sounds)) {
573
+ allCategories.add(cat);
626
574
  }
627
-
628
- catIdx++;
629
- }
630
- } finally {
631
- for (const dir of tmpDirs) {
632
- fs.rmSync(dir, { recursive: true, force: true });
633
- }
575
+ } catch {}
634
576
  }
635
577
 
636
- // Summary
637
- let total = 0;
638
- print(` ${GREEN}✓${RESET} Configuration updated!`);
639
- print(" ─────────────────────────────────────");
640
-
641
- for (const cat of categories) {
578
+ for (const cat of allCategories) {
642
579
  const catDir = path.join(SOUNDS_DIR, cat);
643
- let count = 0;
644
580
  try {
645
581
  for (const f of fs.readdirSync(catDir)) {
646
- if (f.endsWith(".wav") || f.endsWith(".mp3")) count++;
582
+ if (f.endsWith(".wav") || f.endsWith(".mp3")) totalEnabled++;
647
583
  }
648
584
  } catch {}
649
- total += count;
650
- print(` ${cat} (${count}) — ${theme.sounds[cat].description}`);
651
585
  }
652
586
 
653
- print("");
654
- print(` ${total} sound files across ${categories.length} events.`);
655
- print("");
587
+ if (totalEnabled === 0) return null;
588
+
589
+ const displays = themeNames.map((n) => {
590
+ try { return readThemeJson(n).name; } catch { return n; }
591
+ });
592
+
593
+ return {
594
+ themes: themeNames,
595
+ themeDisplays: displays,
596
+ totalEnabled,
597
+ mode: installed.mode || "quick",
598
+ };
656
599
  }
657
600
 
658
- // ─── Install Flow ───────────────────────────────────────────────────────────
601
+ // ─── Quick Install ───────────────────────────────────────────────────────────
659
602
 
660
- async function interactiveInstall(autoYes) {
661
- print("");
662
- print(` ${BOLD}claude-code-sounds${RESET}`);
663
- print(" ──────────────────────────────");
664
- print("");
603
+ async function quickInstall(theme) {
604
+ const themeJson = readThemeJson(theme.name);
605
+ const categories = Object.keys(themeJson.sounds);
665
606
 
666
- // ── Detect existing install ───────────────────────────────────────────────
607
+ for (const cat of categories) mkdirp(path.join(SOUNDS_DIR, cat));
667
608
 
668
- const existingInstall = getExistingInstall();
609
+ // Build selections: all native sounds per category
610
+ const selections = {};
611
+ for (const cat of categories) {
612
+ selections[cat] = themeJson.sounds[cat].files.map((f) => ({
613
+ themeName: theme.name,
614
+ fileName: f.name,
615
+ }));
616
+ }
669
617
 
670
- if (existingInstall && !autoYes) {
671
- print(` ${GREEN}✓${RESET} Already installed ${BOLD}${existingInstall.themeDisplay}${RESET}`);
672
- print(` ${existingInstall.totalEnabled}/${existingInstall.totalAvailable} sounds enabled\n`);
618
+ const total = installSounds(selections);
619
+ writeInstalled({ themes: [theme.name], mode: "quick" });
620
+ installHooksConfig();
673
621
 
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
- ]);
622
+ p.log.success(`Installed ${total} sounds across ${categories.length} hooks.`);
623
+ }
679
624
 
680
- if (actionIdx === 0) {
681
- await reconfigure(existingInstall);
682
- return;
683
- }
684
- if (actionIdx === 2) {
685
- uninstall();
686
- return;
625
+ // ─── Custom Install ──────────────────────────────────────────────────────────
626
+
627
+ async function customInstall(selectedThemes) {
628
+ const themeData = {};
629
+ for (const theme of selectedThemes) {
630
+ themeData[theme.name] = readThemeJson(theme.name);
631
+ }
632
+
633
+ // Build rows (sound items + group headers)
634
+ const rows = [];
635
+ for (const theme of selectedThemes) {
636
+ const themeJson = themeData[theme.name];
637
+ rows.push({ type: 'header', theme: theme.name, label: theme.display });
638
+
639
+ const seenFiles = new Set();
640
+ for (const cat of Object.keys(themeJson.sounds)) {
641
+ for (const file of themeJson.sounds[cat].files) {
642
+ const key = `${theme.name}:${file.name}`;
643
+ if (seenFiles.has(key)) continue;
644
+ seenFiles.add(key);
645
+
646
+ const srcPath = resolveThemeSoundPath(theme.name, file.name);
647
+ rows.push({
648
+ type: 'sound',
649
+ theme: theme.name,
650
+ label: file.name.replace(/\.(wav|mp3)$/, ''),
651
+ fileName: file.name,
652
+ previewPath: fs.existsSync(srcPath) ? srcPath : undefined,
653
+ });
654
+ }
687
655
  }
688
- // actionIdx === 1 falls through to full install
689
656
  }
690
657
 
691
- // ── Step 1: Dependency Check ──────────────────────────────────────────────
658
+ // Build initial grid: pre-check each sound for its native hook(s)
659
+ const soundOnlyRows = rows.filter(r => r.type === 'sound');
660
+ const initialGrid = soundOnlyRows.map(soundRow => {
661
+ return HOOKS.map(hook => {
662
+ const themeJson = themeData[soundRow.theme];
663
+ const catSounds = themeJson.sounds[hook.key];
664
+ if (!catSounds) return false;
665
+ return catSounds.files.some(f => f.name === soundRow.fileName);
666
+ });
667
+ });
668
+
669
+ const gridResult = await new SoundGrid({
670
+ message: 'Assign sounds to hooks',
671
+ rows,
672
+ hooks: HOOKS,
673
+ initialGrid,
674
+ }).prompt();
692
675
 
693
- const deps = ["afplay", "curl", "unzip"];
694
- const missing = [];
676
+ killPreview();
677
+
678
+ if (p.isCancel(gridResult)) {
679
+ p.cancel("Cancelled.");
680
+ process.exit(0);
681
+ }
695
682
 
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);
683
+ // Convert grid result to selections format for installSounds()
684
+ const selections = {};
685
+ for (const hook of HOOKS) {
686
+ const items = gridResult[hook.key];
687
+ if (items && items.length > 0) {
688
+ selections[hook.key] = items.map(item => ({
689
+ themeName: item.theme,
690
+ fileName: item.fileName,
691
+ }));
704
692
  }
705
693
  }
706
- print("");
707
694
 
708
- if (missing.includes("afplay")) {
709
- die("afplay is not available. claude-code-sounds requires macOS.");
695
+ for (const cat of Object.keys(selections)) {
696
+ mkdirp(path.join(SOUNDS_DIR, cat));
710
697
  }
711
698
 
712
- if (missing.length > 0) {
713
- if (autoYes) {
714
- die(`Missing dependencies: ${missing.join(", ")}. Install them and try again.`);
715
- }
699
+ const total = installSounds(selections);
700
+ writeInstalled({ themes: selectedThemes.map((t) => t.name), mode: "custom" });
701
+ installHooksConfig();
702
+
703
+ printSummary(selections);
704
+ }
705
+
706
+ // ─── Reconfigure ─────────────────────────────────────────────────────────────
716
707
 
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(" "));
708
+ async function reconfigure(existingInstall) {
709
+ const allThemes = listThemes();
710
+
711
+ // Theme selection with current themes pre-checked
712
+ const themeValues = await p.multiselect({
713
+ message: "Select themes to include:",
714
+ options: allThemes.map((t) => ({
715
+ value: t.name,
716
+ label: t.display,
717
+ hint: `${t.soundCount} sounds — from ${t.sources.join(", ") || "local"}`,
718
+ })),
719
+ initialValues: existingInstall.themes,
720
+ required: true,
721
+ });
722
+
723
+ if (p.isCancel(themeValues)) {
724
+ p.cancel("Cancelled.");
725
+ process.exit(0);
726
+ }
727
+
728
+ const selectedThemes = allThemes.filter((t) => themeValues.includes(t.name));
729
+ const themeData = {};
730
+ for (const theme of selectedThemes) {
731
+ themeData[theme.name] = readThemeJson(theme.name);
732
+ }
733
+
734
+ // Get currently installed files per category
735
+ const currentFiles = {};
736
+ for (const hook of HOOKS) {
737
+ currentFiles[hook.key] = new Set();
738
+ const catDir = path.join(SOUNDS_DIR, hook.key);
739
+ try {
740
+ for (const f of fs.readdirSync(catDir)) {
741
+ if (f.endsWith(".wav") || f.endsWith(".mp3")) {
742
+ currentFiles[hook.key].add(f);
743
+ }
723
744
  }
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(" "));
745
+ } catch {}
746
+ }
747
+
748
+ // Build rows
749
+ const rows = [];
750
+ for (const theme of selectedThemes) {
751
+ const themeJson = themeData[theme.name];
752
+ rows.push({ type: 'header', theme: theme.name, label: theme.display });
753
+
754
+ const seenFiles = new Set();
755
+ for (const cat of Object.keys(themeJson.sounds)) {
756
+ for (const file of themeJson.sounds[cat].files) {
757
+ const key = `${theme.name}:${file.name}`;
758
+ if (seenFiles.has(key)) continue;
759
+ seenFiles.add(key);
760
+
761
+ const srcPath = resolveThemeSoundPath(theme.name, file.name);
762
+ rows.push({
763
+ type: 'sound',
764
+ theme: theme.name,
765
+ label: file.name.replace(/\.(wav|mp3)$/, ''),
766
+ fileName: file.name,
767
+ previewPath: fs.existsSync(srcPath) ? srcPath : undefined,
768
+ });
730
769
  }
731
- } else {
732
- die("Missing dependencies. Install them manually:\n brew install " + missing.join(" "));
733
770
  }
734
771
  }
735
772
 
736
- // ── Step 2: Theme Selection ───────────────────────────────────────────────
773
+ // Build initial grid from currently installed files
774
+ const soundOnlyRows = rows.filter(r => r.type === 'sound');
775
+ const initialGrid = soundOnlyRows.map(soundRow => {
776
+ return HOOKS.map(hook => {
777
+ return currentFiles[hook.key]?.has(soundRow.fileName) || false;
778
+ });
779
+ });
737
780
 
738
- const themes = listThemes();
739
- let selectedTheme;
781
+ const gridResult = await new SoundGrid({
782
+ message: 'Assign sounds to hooks',
783
+ rows,
784
+ hooks: HOOKS,
785
+ initialGrid,
786
+ }).prompt();
740
787
 
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
- }
788
+ killPreview();
751
789
 
752
- // ── Step 3: Download ──────────────────────────────────────────────────────
790
+ if (p.isCancel(gridResult)) {
791
+ p.cancel("Cancelled.");
792
+ process.exit(0);
793
+ }
753
794
 
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);
795
+ // Convert grid result to selections
796
+ const selections = {};
797
+ for (const hook of HOOKS) {
798
+ const items = gridResult[hook.key];
799
+ if (items && items.length > 0) {
800
+ selections[hook.key] = items.map(item => ({
801
+ themeName: item.theme,
802
+ fileName: item.fileName,
803
+ }));
804
+ }
805
+ }
758
806
 
759
- // Create directories
760
- for (const cat of categories) {
807
+ for (const cat of Object.keys(selections)) {
761
808
  mkdirp(path.join(SOUNDS_DIR, cat));
762
809
  }
763
- mkdirp(HOOKS_DIR);
764
810
 
765
- print(" Downloading sounds...");
766
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-sounds-"));
811
+ const total = installSounds(selections);
812
+ writeInstalled({ themes: selectedThemes.map((t) => t.name), mode: "custom" });
813
+ installHooksConfig();
767
814
 
768
- try {
769
- const downloadScript = path.join(themeDir, "download.sh");
770
- if (fs.existsSync(downloadScript)) {
771
- exec(`bash "${downloadScript}" "${SOUNDS_DIR}" "${tmpDir}"`, { stdio: "inherit" });
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
- }
815
+ printSummary(selections);
786
816
 
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
- }
817
+ return true;
818
+ }
814
819
 
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
- }
820
+ // ─── Interactive Install ─────────────────────────────────────────────────────
827
821
 
828
- selections[cat] = selected;
829
- catIdx++;
830
- }
831
- }
832
- }
822
+ async function interactiveInstall(autoYes) {
823
+ p.intro(color.bold("claude-code-sounds"));
833
824
 
834
- // ── Step 5: Install & Summary ─────────────────────────────────────────
825
+ checkDependencies();
835
826
 
836
- print(" Installing sounds...");
827
+ const existing = detectExistingInstall();
837
828
 
838
- // Clear existing sounds and .disabled dirs
839
- for (const cat of categories) {
840
- const catDir = path.join(SOUNDS_DIR, cat);
841
- for (const f of fs.readdirSync(catDir)) {
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);
847
- }
848
- }
829
+ if (existing && !autoYes) {
830
+ p.log.info(
831
+ `Already installed ${color.bold(existing.themeDisplays.join(", "))} (${existing.totalEnabled} sounds enabled)`
832
+ );
833
+
834
+ const action = await p.select({
835
+ message: "What would you like to do?",
836
+ options: [
837
+ { value: "modify", label: "Modify install", hint: "Add themes, change sounds" },
838
+ { value: "fresh", label: "Fresh install", hint: "Start over from scratch" },
839
+ { value: "uninstall", label: "Uninstall", hint: "Remove all sounds and hooks" },
840
+ ],
841
+ });
842
+
843
+ if (p.isCancel(action)) {
844
+ p.cancel("Cancelled.");
845
+ process.exit(0);
849
846
  }
850
847
 
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;
869
- }
848
+ if (action === "uninstall") {
849
+ uninstallAll();
850
+ p.outro("All sounds removed.");
851
+ return;
852
+ }
870
853
 
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));
878
- }
879
- // Unselected borrowed sounds: skip (no need to store)
854
+ if (action === "modify") {
855
+ const ok = await reconfigure(existing);
856
+ if (ok) {
857
+ p.outro("Start a new Claude Code session to hear it.");
858
+ return;
880
859
  }
860
+ // Fall through to fresh install if reconfigure failed
881
861
  }
862
+ // "fresh" falls through
863
+ }
882
864
 
883
- // Write install marker
884
- writeInstalled(selectedTheme.name);
865
+ const themes = listThemes();
866
+ if (themes.length === 0) {
867
+ p.cancel("No themes found in themes/ directory.");
868
+ process.exit(1);
869
+ }
885
870
 
886
- // Copy play-sound.sh hook
887
- const hookSrc = path.join(PKG_DIR, "hooks", "play-sound.sh");
888
- const hookDest = path.join(HOOKS_DIR, "play-sound.sh");
889
- fs.copyFileSync(hookSrc, hookDest);
890
- fs.chmodSync(hookDest, 0o755);
871
+ // --yes: quick install first theme
872
+ if (autoYes) {
873
+ await quickInstall(themes[0]);
874
+ p.log.info(`To customize which sounds play on each hook, run:\n${color.gray(p.S_BAR)}\n${color.gray(p.S_BAR)} ${color.cyan("npx claude-code-sounds")}\n${color.gray(p.S_BAR)}\n${color.gray(p.S_BAR)} and choose ${color.bold("Modify install")}.`);
875
+ p.outro("Start a new Claude Code session to hear it.");
876
+ return;
877
+ }
891
878
 
892
- // Merge hooks into settings.json
893
- const settings = readSettings();
894
- settings.hooks = HOOKS_CONFIG;
895
- writeSettings(settings);
879
+ const mode = await p.select({
880
+ message: "How do you want to install?",
881
+ options: [
882
+ { value: "quick", label: "Quick install", hint: "One theme, all defaults" },
883
+ { value: "custom", label: "Custom mix", hint: "Pick sounds per hook from multiple themes" },
884
+ ],
885
+ });
896
886
 
897
- // Summary
898
- print("");
899
- print(` ${GREEN}✓${RESET} Installed! Here's what you'll hear:`);
900
- print(" ─────────────────────────────────────");
887
+ if (p.isCancel(mode)) {
888
+ p.cancel("Cancelled.");
889
+ process.exit(0);
890
+ }
901
891
 
902
- for (const cat of categories) {
903
- const count = selections[cat].length;
904
- print(` ${cat} (${count}) ${theme.sounds[cat].description}`);
892
+ if (mode === "quick") {
893
+ const themeValue = await p.select({
894
+ message: "Select a theme:",
895
+ options: themes.map((t) => ({
896
+ value: t.name,
897
+ label: t.display,
898
+ hint: `${t.soundCount} sounds — ${t.description} [${t.sources.join(", ") || "local"}]`,
899
+ })),
900
+ });
901
+
902
+ if (p.isCancel(themeValue)) {
903
+ p.cancel("Cancelled.");
904
+ process.exit(0);
905
905
  }
906
906
 
907
- print("");
908
- print(` ${total} sound files across ${categories.length} events.`);
909
- print(" Start a new Claude Code session to hear it!");
910
- print("");
911
- print(" Zug zug.");
912
- print("");
913
- } finally {
914
- killPreview();
915
- fs.rmSync(tmpDir, { recursive: true, force: true });
907
+ await quickInstall(themes.find((t) => t.name === themeValue));
908
+ p.log.info(`To customize which sounds play on each hook, run:\n${color.gray(p.S_BAR)}\n${color.gray(p.S_BAR)} ${color.cyan("npx claude-code-sounds")}\n${color.gray(p.S_BAR)}\n${color.gray(p.S_BAR)} and choose ${color.bold("Modify install")}.`);
909
+ } else {
910
+ const themeValues = await p.multiselect({
911
+ message: "Select themes to include:",
912
+ options: themes.map((t) => ({
913
+ value: t.name,
914
+ label: t.display,
915
+ hint: `${t.soundCount} sounds — from ${t.sources.join(", ") || "local"}`,
916
+ })),
917
+ required: true,
918
+ });
919
+
920
+ if (p.isCancel(themeValues)) {
921
+ p.cancel("Cancelled.");
922
+ process.exit(0);
923
+ }
924
+
925
+ await customInstall(themes.filter((t) => themeValues.includes(t.name)));
916
926
  }
927
+
928
+ p.outro("Start a new Claude Code session to hear it.");
917
929
  }
918
930
 
919
931
  // ─── Main ────────────────────────────────────────────────────────────────────
@@ -922,17 +934,18 @@ const args = process.argv.slice(2);
922
934
  const flags = new Set(args);
923
935
  const autoYes = flags.has("--yes") || flags.has("-y");
924
936
 
925
- // Handle non-interactive commands first
926
937
  if (flags.has("--help") || flags.has("-h")) {
927
938
  showHelp();
928
939
  } else if (flags.has("--list") || flags.has("-l")) {
929
940
  showList();
930
941
  } else if (flags.has("--uninstall") || flags.has("--remove")) {
931
- uninstall();
942
+ p.intro(color.bold("claude-code-sounds"));
943
+ uninstallAll();
944
+ p.outro("All sounds removed.");
932
945
  } else {
933
946
  interactiveInstall(autoYes).catch((err) => {
934
947
  killPreview();
935
- process.stdout.write(SHOW_CURSOR);
936
- die(err.message);
948
+ p.cancel(err.message);
949
+ process.exit(1);
937
950
  });
938
951
  }