Meme sounds on events: FAAAH when tests fail, anime wow when they pass, bruh when Claude asks you something; failed builds, commands, rejected pushes, denials…

Meme sounds when things happen. Out of the box:

| When | Sound |
|---|---|
| a test run fails | FAAAH |
| a test run passes | anime wow |
| Claude asks you something: a permission prompt, a question, a plan to approve | bruh |
Tests are any common runner (npm test, pytest, vitest, go test, cargo test, claude plugin test…). A run counts as failed when it exits with an error or prints failures through | tail. Bruh plays only when a dialog is on screen waiting for you: a call that auto mode or your rules settle by themselves stays quiet.
| Event | When | Sound |
|---|---|---|
build-failed | a build or type check fails (npm run build, tsc, cargo build, make…) | faah |
command-failed | any other shell command fails | faah |
push-rejected | git push is rejected | faah |
denied | a tool call is denied | bruh |
turn-error | a turn ends in an error | faah |
interrupted | you stop Claude mid-turn | bruh |
The same event again within 2.5 seconds plays once.
| Command | What it does |
|---|---|
/faah | Shows each event, whether it's on, and its sound |
/faah test, /faah test bruh, /faah test tests-passed | Plays a clip, or an event's sound, now |
/faah add build-failed | Turns an event on (several at once: add denied, turn-error) |
/faah remove tests-passed | Turns an event off |
/faah sound asking anime-wow | Gives an event another clip: faah, anime-wow or bruh |
/faah sound asking ~/Downloads/clip.mp3 | Gives an event your own file (anything afplay plays) |
/faah sound asking default | Back to the event's own clip |
/faah off / /faah on | Mutes and unmutes everything |
The starting events and the volume (0 to 4) are also options: /plugin configure faah@o-mods.
The meme clips from Remotion's sound library: https://remotion.media/fah.wav, anime-wow.wav and bruh.wav, collected there from meme soundboards. They are not under a free license, so they are not in this repository: the mod fetches each once into ~/Library/Caches/faah/ and plays it from there.
Sound plays through afplay, so on macOS only; elsewhere it stays silent.
hooks/register.ts 175 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { ASKING_NOTIFICATIONS, ASKING_TOOLS, bashEvent, CLIP_FILES, CLIPS, DEFAULT_EVENTS, DEFAULT_SOUNDS, EVENTS, isClip, parseEvents } from './events'
4import type { Clip, FaahEvent } from './events'
5
6/**
7 * The clips come from Remotion's sound library (https://www.remotion.dev/docs/sfx), which collected them from
8 * meme soundboards. They are not under a free license, so the mod does not ship them: each is fetched once into
9 * your cache and played from there.
10 */
11const CLIP_URL = (clip: Clip) => `https://remotion.media/${CLIP_FILES[clip]}.wav`
12/** One sound per mishap: the same event again within this plays once. */
13const COOLDOWN_MS = 2500
14const STORE = { on: 'on', events: 'events', sounds: 'sounds' }
15
16let configured: FaahEvent[] = DEFAULT_EVENTS
17let gain = 1
18const lastPlayed = new Map<FaahEvent, number>()
19
20type Sound = Clip | { file: string }
21
22async function settings($: EngineInterface): Promise<{ on: boolean; events: FaahEvent[]; sounds: Partial<Record<FaahEvent, Sound>> }> {
23 const stored = (await $.store.get(STORE.events)) as string[] | undefined
24 return {
25 on: (await $.store.get(STORE.on)) !== false,
26 events: stored ? parseEvents(stored.join(',')) : configured,
27 sounds: ((await $.store.get(STORE.sounds)) as Partial<Record<FaahEvent, Sound>> | undefined) ?? {},
28 }
29}
30
31async function cacheFolder($: EngineInterface): Promise<string> {
32 return `${await $.env.get('HOME')}/Library/Caches/faah`
33}
34
35/** Fetches a clip into the cache once; later sessions find it there. Resolves to its file, or null. */
36async function fetchClip($: EngineInterface, clip: Clip): Promise<string | null> {
37 const folder = await cacheFolder($)
38 const file = `${folder}/${CLIP_FILES[clip]}.wav`
39 if (await $.fs.exists(file)) return file
40 const ran = await $.process.run(['sh', '-c', 'mkdir -p "$1" && curl -fsSL "$2" -o "$3.part" && mv "$3.part" "$3"', 'sh', folder, CLIP_URL(clip), file], { timeoutMs: 30_000 })
41 return ran.exitCode === 0 ? file : null
42}
43
44/** Plays a sound: your own file, or a clip from the cache, else straight from its URL. */
45async function play($: EngineInterface, sound: Sound) {
46 const file = typeof sound === 'string' ? await fetchClip($, sound).catch(() => null) : sound.file
47 if (file) {
48 const ran = await $.process.run(['afplay', '-v', String(gain), file], { timeoutMs: 15_000 })
49 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `afplay could not play ${file}`)
50 return
51 }
52 if (typeof sound === 'string') await $.audio.play({ url: CLIP_URL(sound) }, { gain })
53}
54
55async function maybePlay($: EngineInterface, event: FaahEvent) {
56 const now = await $.clock.now()
57 const current = await settings($)
58 if (!current.on || !current.events.includes(event) || now - (lastPlayed.get(event) ?? -Infinity) < COOLDOWN_MS) return
59 lastPlayed.set(event, now)
60 void play($, current.sounds[event] ?? DEFAULT_SOUNDS[event]).catch(() => undefined)
61}
62
63function outputText(result: unknown): string {
64 const record = (result as { result?: unknown }).result ?? result
65 if (typeof record === 'string') return record
66 const { stdout, stderr } = (record ?? {}) as { stdout?: unknown; stderr?: unknown }
67 return [stdout, stderr].filter(part => typeof part === 'string').join('\n')
68}
69
70function soundName(sound: Sound): string {
71 return typeof sound === 'string' ? sound : sound.file
72}
73
74async function status($: EngineInterface): Promise<string> {
75 const current = await settings($)
76 const rows = (Object.keys(EVENTS) as FaahEvent[]).map(name => {
77 const sound = soundName(current.sounds[name] ?? DEFAULT_SOUNDS[name])
78 return `${current.events.includes(name) ? '●' : '○'} ${name.padEnd(15)}${sound.padEnd(11)}${EVENTS[name]}`
79 })
80 return [
81 `Sounds are ${current.on ? 'on' : 'off'}.`,
82 '',
83 ...rows,
84 '',
85 `/faah test [event|clip] · /faah on|off · /faah add|remove <event> · /faah sound <event> <${CLIPS.join('|')}|file|default>`,
86 ].join('\n')
87}
88
89export const register: Register = (on, options) => {
90 const asked = options as { events?: string; volume?: string }
91 if (typeof asked.events === 'string' && asked.events.trim() !== '') configured = parseEvents(asked.events)
92 const volume = Number(asked.volume)
93 if (Number.isFinite(volume) && volume >= 0 && volume <= 4) gain = volume
94
95 on('session.start', async ($, e, next) => {
96 // The clips are small: fetch any missing now, so the first one plays at once.
97 for (const clip of CLIPS) void fetchClip($, clip).catch(() => undefined)
98 // Not awaited: registering makes Claude Code re-describe every command, which would hold startup.
99 void $.command.register({ name: 'faah', description: 'Meme sounds on events: FAAAH on failed tests, anime wow on passing ones, bruh when Claude asks you', argumentHint: '[test|on|off|add <event>|remove <event>|sound <event> <clip|file>]' }).catch(() => undefined)
100 return next(e)
101 })
102
103 on('tool.call', async ($, e, next) => {
104 // A question or a plan to approve: the dialog opens as the call runs.
105 if (ASKING_TOOLS.has(e.tool)) void maybePlay($, 'asking').catch(() => undefined)
106 const result = await next(e)
107 if ('deny' in result) {
108 void maybePlay($, 'denied').catch(() => undefined)
109 return result
110 }
111 if (e.tool === 'Bash') {
112 const command = String((e as { command?: string }).command ?? '')
113 const isError = 'isError' in result && result.isError === true
114 const event = bashEvent(command, { isError, text: outputText(result) })
115 if (event) void maybePlay($, event).catch(() => undefined)
116 }
117 return result
118 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
119
120 // A dialog waiting on you: the engine raises this notification only once a permission prompt (or an MCP
121 // server's question) is actually on screen, never for a call auto mode or a rule settled by itself.
122 on('classic.Notification', async ($, e, next) => {
123 if (ASKING_NOTIFICATIONS.has(e.notification_type)) void maybePlay($, 'asking').catch(() => undefined)
124 return next(e)
125 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
126
127 on('turn.complete', async ($, e, next) => {
128 const result = await next(e)
129 if (e.reason === 'error') void maybePlay($, 'turn-error').catch(() => undefined)
130 if (e.reason === 'aborted') void maybePlay($, 'interrupted').catch(() => undefined)
131 return result
132 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
133
134 on('command.run', { command: ['faah', 'faah:faah'] }, async ($, e) => {
135 const [verb = '', first = '', ...rest] = e.args.trim().split(/\s+/)
136 const current = await settings($)
137 if (verb === 'test') {
138 const sound: Sound = isClip(first) ? first : first in EVENTS ? (current.sounds[first as FaahEvent] ?? DEFAULT_SOUNDS[first as FaahEvent]) : 'faah'
139 try {
140 await play($, sound)
141 return { text: `Played ${soundName(sound)}.` }
142 } catch (error) {
143 return { text: `Could not play it: ${(error as Error).message}` }
144 }
145 }
146 if (verb === 'on' || verb === 'off') {
147 await $.store.set(STORE.on, verb === 'on')
148 return { text: `Sounds ${verb}.` }
149 }
150 if (verb === 'add' || verb === 'remove') {
151 const picked = parseEvents([first, ...rest].join(' '))
152 if (picked.length === 0) return { text: `Events: ${Object.keys(EVENTS).join(', ')}.` }
153 const events = verb === 'add' ? [...new Set([...current.events, ...picked])] : current.events.filter(name => !picked.includes(name))
154 await $.store.set(STORE.events, events)
155 return { text: await status($) }
156 }
157 if (verb === 'sound') {
158 if (!(first in EVENTS)) return { text: `usage: /faah sound <event> <${CLIPS.join('|')}|file|default>. Events: ${Object.keys(EVENTS).join(', ')}.` }
159 const event = first as FaahEvent
160 const choice = rest.join(' ').trim().replace(/^['"]|['"]$/g, '')
161 const sounds = { ...current.sounds }
162 if (choice === '' || choice === 'default') delete sounds[event]
163 else if (isClip(choice)) sounds[event] = choice
164 else {
165 const file = choice.replace(/^~(?=\/)/, (await $.env.get('HOME')) ?? '~')
166 if (!(await $.fs.exists(file))) return { text: `No file at ${file}.` }
167 sounds[event] = { file }
168 }
169 await $.store.set(STORE.sounds, sounds)
170 return { text: `${event} now plays ${soundName(sounds[event] ?? DEFAULT_SOUNDS[event])}. /faah test ${event} to hear it.` }
171 }
172 return { text: await status($) }
173 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
174}
175hooks/events.ts 70 lines1/**
2 * The events the mod plays on, the clips it plays, and which event a finished command is. Pure functions, so
3 * the rules are testable.
4 */
5
6export const EVENTS = {
7 'tests-failed': 'a test run fails',
8 'tests-passed': 'a test run passes',
9 asking: 'Claude asks you something (a permission, a question, a plan)',
10 'build-failed': 'a build or type check fails',
11 'command-failed': 'any other shell command fails',
12 'push-rejected': 'git push is rejected',
13 denied: 'a tool call is denied',
14 'turn-error': 'a turn ends in an error',
15 interrupted: 'you stop Claude mid-turn',
16} as const
17
18export type FaahEvent = keyof typeof EVENTS
19
20/** The clips, each fetched once from Remotion's sound library (https://remotion.media/<name>.wav). */
21export const CLIPS = ['faah', 'anime-wow', 'bruh'] as const
22export type Clip = (typeof CLIPS)[number]
23
24export const CLIP_FILES: Record<Clip, string> = { faah: 'fah', 'anime-wow': 'anime-wow', bruh: 'bruh' }
25
26export const DEFAULT_EVENTS: FaahEvent[] = ['tests-failed', 'tests-passed', 'asking']
27
28/** Which clip each event plays until you pick another with /faah sound. */
29export const DEFAULT_SOUNDS: Record<FaahEvent, Clip> = {
30 'tests-failed': 'faah',
31 'tests-passed': 'anime-wow',
32 asking: 'bruh',
33 'build-failed': 'faah',
34 'command-failed': 'faah',
35 'push-rejected': 'faah',
36 denied: 'bruh',
37 'turn-error': 'faah',
38 interrupted: 'bruh',
39}
40
41/** Tools whose call puts a question to you: they open a dialog however permissions are set. */
42export const ASKING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
43/** Notification types that mean a dialog is open and waiting on you (not the idle reminder). */
44export const ASKING_NOTIFICATIONS = new Set(['permission_prompt', 'elicitation_dialog'])
45
46const TEST_COMMAND = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(jest|vitest|pytest|mocha|rspec|phpunit|playwright)\b|\b(go|cargo|deno|bun|swift|dotnet)\s+test\b|\bclaude\s+plugin\s+test\b|\bmake\s+(test|check)\b|\bpython3?\s+-m\s+(pytest|unittest)\b|\bgradlew?\s+test\b|\bmvn\s+test\b/
47const BUILD_COMMAND = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(build|typecheck|type-check|lint)\b|\btsc\b|\b(cargo|go|swift)\s+build\b|\bmake\b|\bgradlew?\s+(build|assemble)\b|\bmvn\s+(package|compile|install)\b|\bxcodebuild\b|\bdocker\s+build\b/
48const PUSH_COMMAND = /\bgit\s+push\b/
49
50/** A test runner's summary that names failures, for runs piped through tail or grep that exit 0. */
51const FAILURES_IN_OUTPUT = /\b[1-9]\d*\s+(failed|failing|failures?|errors?)\b|^\s*FAIL\b|\bFAILED\b|^\(fail\)|\b[1-9]\d*\s+fail\b|Tests?:\s+[1-9]\d*\s+failed/im
52
53export function parseEvents(text: string): FaahEvent[] {
54 return [...new Set(text.split(/[\s,]+/).filter((name): name is FaahEvent => name in EVENTS))]
55}
56
57export function isClip(name: string): name is Clip {
58 return (CLIPS as readonly string[]).includes(name)
59}
60
61/** The event a finished Bash command is, if any. */
62export function bashEvent(command: string, output: { isError: boolean; text: string }): FaahEvent | null {
63 if (TEST_COMMAND.test(command)) return output.isError || FAILURES_IN_OUTPUT.test(output.text) ? 'tests-failed' : 'tests-passed'
64 if (!output.isError) return null
65 if (PUSH_COMMAND.test(command)) return 'push-rejected'
66 if (BUILD_COMMAND.test(command)) return 'build-failed'
67 return 'command-failed'
68}
69
70