claude-sound 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # claude-sound
2
+
3
+ macOS-only CLI that configures **Claude Code Hooks** to play **bundled sounds** using `afplay`.
4
+
5
+ - Setup UI: `npx claude-sound@latest`
6
+ - Hook runner: `npx --yes claude-sound@latest play --event <Event> --sound <SoundId> --managed-by claude-sound`
7
+
8
+ ## Install / run
9
+
10
+ ```bash
11
+ npx claude-sound@latest
12
+ ```
13
+
14
+ You’ll be prompted to choose where to write settings:
15
+
16
+ - Project (shared): `.claude/settings.json`
17
+ - Project (local): `.claude/settings.local.json`
18
+ - Global: `~/.claude/settings.json`
19
+
20
+ Then you can enable/disable events and choose a sound per event. Selecting a sound plays a quick preview.
21
+
22
+ ## Commands
23
+
24
+ ```bash
25
+ claude-sound list-events
26
+ claude-sound list-sounds
27
+ claude-sound play --sound ring1
28
+ ```
29
+
30
+ ## What gets written
31
+
32
+ For each configured event, `claude-sound` writes a Claude hook handler like:
33
+
34
+ ```json
35
+ {
36
+ "hooks": {
37
+ "SessionStart": [
38
+ {
39
+ "matcher": "*",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "npx --yes claude-sound@latest play --event SessionStart --sound ring1 --managed-by claude-sound",
44
+ "async": true,
45
+ "timeout": 5
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
51
+ }
52
+ ```
53
+
54
+ `claude-sound` only manages hook handlers whose `command` contains `--managed-by claude-sound`.
55
+
56
+ ## Uninstall / remove hooks
57
+
58
+ Run the setup again and choose **Remove all claude-sound hooks**, then **Apply**.
59
+
60
+ Or manually delete any hook handlers whose command contains:
61
+
62
+ ```
63
+ --managed-by claude-sound
64
+ ```
65
+
66
+ ## Notes
67
+
68
+ - macOS only (requires `afplay`).
69
+ - Hooks run `npx` each time the event fires. It’s simple and works everywhere, but may be slower than a local install.
@@ -0,0 +1,42 @@
1
+ [
2
+ {
3
+ "id": "ring1",
4
+ "file": "assets/sounds/ring1.wav"
5
+ },
6
+ {
7
+ "id": "ring2",
8
+ "file": "assets/sounds/ring2.wav"
9
+ },
10
+ {
11
+ "id": "ring3",
12
+ "file": "assets/sounds/ring3.wav"
13
+ },
14
+ {
15
+ "id": "ring4",
16
+ "file": "assets/sounds/ring4.wav"
17
+ },
18
+ {
19
+ "id": "ring5",
20
+ "file": "assets/sounds/ring5.wav"
21
+ },
22
+ {
23
+ "id": "ring6",
24
+ "file": "assets/sounds/ring6.wav"
25
+ },
26
+ {
27
+ "id": "ring7",
28
+ "file": "assets/sounds/ring7.wav"
29
+ },
30
+ {
31
+ "id": "ring8",
32
+ "file": "assets/sounds/ring8.wav"
33
+ },
34
+ {
35
+ "id": "ring9",
36
+ "file": "assets/sounds/ring9.wav"
37
+ },
38
+ {
39
+ "id": "ring10",
40
+ "file": "assets/sounds/ring10.wav"
41
+ }
42
+ ]
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "claude-sound",
3
+ "version": "0.1.0",
4
+ "description": "Configure Claude Code hooks to play bundled sounds on macOS (afplay).",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": ""
9
+ },
10
+ "type": "module",
11
+ "bin": {
12
+ "claude-sound": "./src/cli.js"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "assets"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "generate:sounds": "node ./scripts/generate-sounds.mjs",
23
+ "prepack": "npm run generate:sounds"
24
+ },
25
+ "dependencies": {
26
+ "@clack/prompts": "^0.11.0"
27
+ }
28
+ }
package/src/cli.js ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { intro, outro, select, isCancel, cancel, note, spinner } from '@clack/prompts';
4
+ import process from 'node:process';
5
+ import fs from 'node:fs/promises';
6
+ import { playSound } from './play.js';
7
+ import { listSounds } from './sounds.js';
8
+ import {
9
+ HOOK_EVENTS,
10
+ configPathForScope,
11
+ readJsonIfExists,
12
+ writeJson,
13
+ getExistingManagedMappings,
14
+ applyMappingsToSettings
15
+ } from './hooks.js';
16
+
17
+ function usage(exitCode = 0) {
18
+ process.stdout.write(`\
19
+ claude-sound (macOS)\n\nUsage:\n npx claude-sound@latest Interactive hook sound setup\n claude-sound Interactive hook sound setup\n\n claude-sound play --sound <id> Play a bundled sound (uses afplay)\n claude-sound list-sounds List bundled sound ids\n claude-sound list-events List Claude hook event names\n\nOptions:\n -h, --help Show help\n\nExamples:\n npx claude-sound@latest\n npx claude-sound@latest play --sound ring1\n`);
20
+ process.exit(exitCode);
21
+ }
22
+
23
+ function parseArg(flag) {
24
+ const idx = process.argv.indexOf(flag);
25
+ if (idx === -1) return null;
26
+ return process.argv[idx + 1] ?? null;
27
+ }
28
+
29
+ async function cmdPlay() {
30
+ const soundId = parseArg('--sound');
31
+ if (!soundId) {
32
+ process.stderr.write('Missing --sound <id>\n');
33
+ process.exit(1);
34
+ }
35
+
36
+ try {
37
+ await playSound(soundId);
38
+ } catch (err) {
39
+ process.stderr.write(`Failed to play sound '${soundId}': ${err?.message || err}\n`);
40
+ process.exit(1);
41
+ }
42
+ }
43
+
44
+ async function cmdListSounds() {
45
+ const sounds = await listSounds();
46
+ for (const s of sounds) process.stdout.write(s + '\n');
47
+ }
48
+
49
+ async function cmdListEvents() {
50
+ for (const e of HOOK_EVENTS) process.stdout.write(e + '\n');
51
+ }
52
+
53
+ async function interactiveSetup() {
54
+ intro('claude-sound');
55
+
56
+ const scope = await select({
57
+ message: 'Where do you want to write Claude Code hook settings?',
58
+ options: [
59
+ { value: 'project', label: 'Project (shared): .claude/settings.json' },
60
+ { value: 'projectLocal', label: 'Project (local): .claude/settings.local.json (gitignored)' },
61
+ { value: 'global', label: 'Global: ~/.claude/settings.json' }
62
+ ]
63
+ });
64
+
65
+ if (isCancel(scope)) {
66
+ cancel('Cancelled');
67
+ process.exit(0);
68
+ }
69
+
70
+ const projectDir = process.cwd();
71
+ const settingsPath = configPathForScope(scope, projectDir);
72
+
73
+ const existingRes = await readJsonIfExists(settingsPath);
74
+ if (!existingRes.ok) {
75
+ cancel(`Could not read/parse JSON at ${settingsPath}`);
76
+ note(String(existingRes.error?.message || existingRes.error), 'Error');
77
+ process.exit(1);
78
+ }
79
+
80
+ let settings = existingRes.value;
81
+
82
+ // Load existing mappings we previously wrote.
83
+ /** @type {Record<string, string>} */
84
+ let mappings = getExistingManagedMappings(settings);
85
+
86
+ const sounds = await listSounds();
87
+
88
+ // main loop
89
+ while (true) {
90
+ const options = HOOK_EVENTS.map((eventName) => {
91
+ const soundId = mappings[eventName];
92
+ const enabled = Boolean(soundId);
93
+ return {
94
+ value: eventName,
95
+ label: `${enabled ? '[x]' : '[ ]'} ${eventName}${soundId ? ` → ${soundId}` : ''}`
96
+ };
97
+ });
98
+
99
+ options.push({ value: '__apply__', label: 'Apply (write settings)' });
100
+ options.push({ value: '__remove_all__', label: 'Remove all claude-sound hooks' });
101
+ options.push({ value: '__exit__', label: 'Exit (no changes)' });
102
+
103
+ const choice = await select({
104
+ message: `Configure hook sounds (${settingsPath})`,
105
+ options
106
+ });
107
+
108
+ if (isCancel(choice) || choice === '__exit__') {
109
+ cancel('No changes written');
110
+ process.exit(0);
111
+ }
112
+
113
+ if (choice === '__remove_all__') {
114
+ mappings = {};
115
+ note('All claude-sound mappings cleared (not written yet). Choose Apply to save.', 'Cleared');
116
+ continue;
117
+ }
118
+
119
+ if (choice === '__apply__') {
120
+ const s = spinner();
121
+ s.start('Writing settings...');
122
+ settings = applyMappingsToSettings(settings, mappings);
123
+ await writeJson(settingsPath, settings);
124
+ s.stop('Done');
125
+ outro(`Saved hooks to ${settingsPath}`);
126
+ return;
127
+ }
128
+
129
+ const eventName = choice;
130
+
131
+ const action = await select({
132
+ message: `Event: ${eventName}`,
133
+ options: [
134
+ { value: 'enable', label: mappings[eventName] ? 'Change sound' : 'Enable & choose sound' },
135
+ { value: 'disable', label: 'Disable (remove mapping)' },
136
+ { value: 'back', label: 'Back' }
137
+ ]
138
+ });
139
+
140
+ if (isCancel(action) || action === 'back') continue;
141
+
142
+ if (action === 'disable') {
143
+ delete mappings[eventName];
144
+ continue;
145
+ }
146
+
147
+ const soundId = await select({
148
+ message: `Pick a sound for ${eventName}`,
149
+ options: sounds.map((id) => ({ value: id, label: id }))
150
+ });
151
+
152
+ if (isCancel(soundId)) continue;
153
+
154
+ // quick preview
155
+ try {
156
+ await playSound(soundId);
157
+ } catch {
158
+ // ignore preview errors
159
+ }
160
+
161
+ mappings[eventName] = soundId;
162
+ }
163
+ }
164
+
165
+ async function main() {
166
+ const args = process.argv.slice(2);
167
+
168
+ if (args.includes('-h') || args.includes('--help')) usage(0);
169
+
170
+ const cmd = args[0];
171
+
172
+ if (!cmd) {
173
+ await interactiveSetup();
174
+ return;
175
+ }
176
+
177
+ if (cmd === 'play') {
178
+ await cmdPlay();
179
+ return;
180
+ }
181
+
182
+ if (cmd === 'list-sounds') {
183
+ await cmdListSounds();
184
+ return;
185
+ }
186
+
187
+ if (cmd === 'list-events') {
188
+ await cmdListEvents();
189
+ return;
190
+ }
191
+
192
+ process.stderr.write(`Unknown command: ${cmd}\n`);
193
+ usage(1);
194
+ }
195
+
196
+ main().catch((err) => {
197
+ process.stderr.write(String(err?.stack || err) + '\n');
198
+ process.exit(1);
199
+ });
package/src/hooks.js ADDED
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ export const HOOK_EVENTS = [
6
+ 'SessionStart',
7
+ 'UserPromptSubmit',
8
+ 'PreToolUse',
9
+ 'PermissionRequest',
10
+ 'PostToolUse',
11
+ 'PostToolUseFailure',
12
+ 'Notification',
13
+ 'SubagentStart',
14
+ 'SubagentStop',
15
+ 'Stop',
16
+ 'TeammateIdle',
17
+ 'TaskCompleted',
18
+ 'PreCompact',
19
+ 'SessionEnd'
20
+ ];
21
+
22
+ export function configPathForScope(scope, projectDir) {
23
+ if (scope === 'global') return path.join(os.homedir(), '.claude', 'settings.json');
24
+ if (scope === 'project') return path.join(projectDir, '.claude', 'settings.json');
25
+ if (scope === 'projectLocal') return path.join(projectDir, '.claude', 'settings.local.json');
26
+ throw new Error(`Unknown scope: ${scope}`);
27
+ }
28
+
29
+ export async function readJsonIfExists(filePath) {
30
+ try {
31
+ const raw = await fs.readFile(filePath, 'utf-8');
32
+ return { ok: true, value: JSON.parse(raw) };
33
+ } catch (err) {
34
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
35
+ return { ok: true, value: {} };
36
+ }
37
+ return { ok: false, error: err };
38
+ }
39
+ }
40
+
41
+ export async function writeJson(filePath, obj) {
42
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
43
+ const text = JSON.stringify(obj, null, 2) + '\n';
44
+ await fs.writeFile(filePath, text);
45
+ }
46
+
47
+ const MANAGED_TOKEN = '--managed-by claude-sound';
48
+
49
+ export function isManagedCommand(command) {
50
+ return typeof command === 'string' && command.includes(MANAGED_TOKEN);
51
+ }
52
+
53
+ export function buildManagedCommand({ eventName, soundId }) {
54
+ // Use --yes to avoid prompts in hook context.
55
+ // Keep args stable so we can parse back.
56
+ return `npx --yes claude-sound@latest play --event ${eventName} --sound ${soundId} ${MANAGED_TOKEN}`;
57
+ }
58
+
59
+ export function extractManagedSoundId(command) {
60
+ const m = /--sound\s+([^\s]+)/.exec(command || '');
61
+ return m ? m[1] : null;
62
+ }
63
+
64
+ export function getExistingManagedMappings(settings) {
65
+ /** @type {Record<string, string>} */
66
+ const map = {};
67
+ const hooks = settings?.hooks;
68
+ if (!hooks || typeof hooks !== 'object') return map;
69
+
70
+ for (const [eventName, groups] of Object.entries(hooks)) {
71
+ if (!Array.isArray(groups)) continue;
72
+ for (const g of groups) {
73
+ const handlers = g?.hooks;
74
+ if (!Array.isArray(handlers)) continue;
75
+ for (const h of handlers) {
76
+ const cmd = h?.command;
77
+ if (isManagedCommand(cmd)) {
78
+ const soundId = extractManagedSoundId(cmd);
79
+ if (soundId) map[eventName] = soundId;
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return map;
86
+ }
87
+
88
+ export function applyMappingsToSettings(settings, mappings) {
89
+ const out = { ...(settings || {}) };
90
+ out.hooks = { ...(out.hooks || {}) };
91
+
92
+ // First: remove all existing managed handlers.
93
+ for (const [eventName, groups] of Object.entries(out.hooks)) {
94
+ if (!Array.isArray(groups)) continue;
95
+
96
+ const newGroups = [];
97
+ for (const g of groups) {
98
+ const handlers = Array.isArray(g?.hooks) ? g.hooks : [];
99
+ const kept = handlers.filter((h) => !isManagedCommand(h?.command));
100
+ if (kept.length > 0) {
101
+ newGroups.push({ ...g, hooks: kept });
102
+ }
103
+ }
104
+
105
+ if (newGroups.length > 0) out.hooks[eventName] = newGroups;
106
+ else delete out.hooks[eventName];
107
+ }
108
+
109
+ // Then: add current mappings.
110
+ // IMPORTANT: do not clobber other user-defined hook groups for the same event.
111
+ for (const [eventName, soundId] of Object.entries(mappings)) {
112
+ if (!soundId) continue;
113
+
114
+ const handler = {
115
+ type: 'command',
116
+ command: buildManagedCommand({ eventName, soundId }),
117
+ async: true,
118
+ timeout: 5
119
+ };
120
+
121
+ const group = {
122
+ matcher: '*',
123
+ hooks: [handler]
124
+ };
125
+
126
+ const existingGroups = Array.isArray(out.hooks[eventName]) ? out.hooks[eventName] : [];
127
+ out.hooks[eventName] = [...existingGroups, group];
128
+ }
129
+
130
+ // Clean up if hooks is now empty
131
+ if (out.hooks && Object.keys(out.hooks).length === 0) {
132
+ delete out.hooks;
133
+ }
134
+
135
+ return out;
136
+ }
package/src/play.js ADDED
@@ -0,0 +1,12 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { resolveSoundPath } from './sounds.js';
3
+
4
+ export function playSound(soundId) {
5
+ const file = resolveSoundPath(soundId);
6
+ return new Promise((resolve, reject) => {
7
+ execFile('afplay', [file], (err) => {
8
+ if (err) reject(err);
9
+ else resolve();
10
+ });
11
+ });
12
+ }
package/src/sounds.js ADDED
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ export function soundsDir() {
9
+ return path.resolve(__dirname, '..', 'assets', 'sounds');
10
+ }
11
+
12
+ export async function listSounds() {
13
+ const manifestPath = path.join(soundsDir(), 'manifest.json');
14
+ const raw = await fs.readFile(manifestPath, 'utf-8');
15
+ /** @type {{id:string,file:string}[]} */
16
+ const items = JSON.parse(raw);
17
+ return items.map((it) => it.id);
18
+ }
19
+
20
+ export function resolveSoundPath(soundId) {
21
+ return path.join(soundsDir(), `${soundId}.wav`);
22
+ }