claude-code-sound-hooks 1.0.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 +36 -0
- package/README.md +93 -0
- package/assets/play.sh +11 -0
- package/assets/sounds/goi-goi-may-xong-goi.mp3 +0 -0
- package/assets/sounds/oi-ban-oi.mp3 +0 -0
- package/bin/cli.js +182 -0
- package/package.json +47 -0
- package/src/fsops.js +72 -0
- package/src/index.js +19 -0
- package/src/paths.js +34 -0
- package/src/platform.js +11 -0
- package/src/preset.js +51 -0
- package/src/settings.js +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 victorluu99
|
|
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
EXCEPTION — bundled audio files
|
|
26
|
+
|
|
27
|
+
The MIT license above covers the source code only. It does NOT cover:
|
|
28
|
+
|
|
29
|
+
assets/sounds/goi-goi-may-xong-goi.mp3
|
|
30
|
+
assets/sounds/oi-ban-oi.mp3
|
|
31
|
+
|
|
32
|
+
These are short Vietnamese meme clips. Copyright in them remains with their
|
|
33
|
+
original creators; they are included here for personal, non-commercial use as
|
|
34
|
+
notification sounds, and are not licensed for redistribution or commercial use.
|
|
35
|
+
Replace them with your own audio via the --stop, --notification and
|
|
36
|
+
--permission flags if you need a clean license.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# claude-code-sound-hooks
|
|
2
|
+
|
|
3
|
+
Nghe được tiếng khi Claude Code làm xong, khi nó xin quyền, và khi nó gọi bạn.
|
|
4
|
+
Cài bằng một lệnh. macOS only.
|
|
5
|
+
|
|
6
|
+
*Sound notifications for Claude Code. One command to install. macOS only.*
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx claude-code-sound-hooks install
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Hoặc cài thẳng từ GitHub, không cần chờ npm:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx github:VictorLuu99/claude-code-sound-hooks install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Mở session Claude Code mới là xong.
|
|
19
|
+
|
|
20
|
+
## Nó cài gì
|
|
21
|
+
|
|
22
|
+
| Hook event | Khi nào kêu | Sound |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `Stop` | Claude trả lời xong | `goi-goi-may-xong-goi.mp3` |
|
|
25
|
+
| `PermissionRequest` | Claude xin quyền chạy gì đó | `oi-ban-oi.mp3` |
|
|
26
|
+
| `Notification` | Claude cần bạn để ý | `/System/Library/Sounds/Glass.aiff` |
|
|
27
|
+
|
|
28
|
+
Sound được copy vào `~/.claude/sounds/claude-code-sound-hooks/`, và ba hook được
|
|
29
|
+
thêm vào `~/.claude/settings.json`.
|
|
30
|
+
|
|
31
|
+
## Lệnh
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx claude-code-sound-hooks install # cài
|
|
35
|
+
npx claude-code-sound-hooks status # xem đang cài gì
|
|
36
|
+
npx claude-code-sound-hooks uninstall # gỡ sạch
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Muốn dùng sound của bạn:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx claude-code-sound-hooks install \
|
|
43
|
+
--stop ~/Music/xong-roi.mp3 \
|
|
44
|
+
--notification /System/Library/Sounds/Ping.aiff \
|
|
45
|
+
--permission ~/Music/cho-em-hoi.mp3
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--config-dir <path>` để trỏ vào một thư mục config khác `~/.claude`.
|
|
49
|
+
|
|
50
|
+
Bất kỳ format nào `afplay` chơi được đều dùng được: mp3, aiff, wav, m4a.
|
|
51
|
+
|
|
52
|
+
## Hook của bạn có bị mất không
|
|
53
|
+
|
|
54
|
+
Không. `install` chỉ *thêm* hook, mọi thứ khác trong `settings.json` giữ nguyên —
|
|
55
|
+
kể cả hook bạn tự viết trên cùng event `Stop`. `uninstall` chỉ xoá đúng hook do
|
|
56
|
+
package này tạo ra.
|
|
57
|
+
|
|
58
|
+
Trước mỗi lần ghi, `settings.json` được backup thành
|
|
59
|
+
`settings.json.backup-<timestamp>` ngay cạnh nó. Chạy `install` nhiều lần cũng
|
|
60
|
+
chỉ có một hook mỗi event, không bị nhân đôi.
|
|
61
|
+
|
|
62
|
+
Nếu `settings.json` đang lỗi JSON, `install` dừng lại và **không ghi gì** — bạn
|
|
63
|
+
sửa file trước đã.
|
|
64
|
+
|
|
65
|
+
## Yêu cầu
|
|
66
|
+
|
|
67
|
+
macOS (dùng `afplay`, có sẵn trong macOS) và Node 18 trở lên.
|
|
68
|
+
`package.json` khai báo `"os": ["darwin"]` nên npm sẽ từ chối cài trên
|
|
69
|
+
Windows/Linux.
|
|
70
|
+
|
|
71
|
+
## Dev
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm test # 35 test, không cần dependency nào
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Không có runtime dependency. Logic merge `settings.json` nằm trong
|
|
78
|
+
[`src/settings.js`](src/settings.js) và thuần object-in / object-out, nên test
|
|
79
|
+
được mà không chạm vào filesystem. [`src/fsops.js`](src/fsops.js) là module duy
|
|
80
|
+
nhất có side effect.
|
|
81
|
+
|
|
82
|
+
Thiết kế đầy đủ:
|
|
83
|
+
[`docs/superpowers/specs/`](docs/superpowers/specs/2026-07-30-claude-code-sound-hooks-design.md).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
Code: MIT — xem [LICENSE](LICENSE).
|
|
88
|
+
|
|
89
|
+
**Audio thì không.** Hai file `assets/sounds/goi-goi-may-xong-goi.mp3` và
|
|
90
|
+
`assets/sounds/oi-ban-oi.mp3` là clip meme tiếng Việt ngắn, bản quyền thuộc về
|
|
91
|
+
người tạo ra chúng, đi kèm đây chỉ để dùng cá nhân, phi thương mại. Chúng **không**
|
|
92
|
+
nằm trong giấy phép MIT. Nếu bạn định dùng cho việc gì khác, thay bằng sound của
|
|
93
|
+
bạn qua các flag `--stop` / `--notification` / `--permission` ở trên.
|
package/assets/play.sh
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Plays one sound for a Claude Code hook.
|
|
3
|
+
#
|
|
4
|
+
# Every hook installed by claude-code-sound-hooks goes through this script, so
|
|
5
|
+
# the path of its directory identifies our hooks when uninstalling. Keep it here.
|
|
6
|
+
#
|
|
7
|
+
# A missing sound exits 0 on purpose: a deleted mp3 should go quiet, not turn
|
|
8
|
+
# every Claude Code stop into a failing hook.
|
|
9
|
+
[ -n "$1" ] && [ -f "$1" ] || exit 0
|
|
10
|
+
|
|
11
|
+
exec afplay "$1"
|
|
Binary file
|
|
Binary file
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
|
|
5
|
+
import { assertSupportedPlatform } from '../src/platform.js';
|
|
6
|
+
import {
|
|
7
|
+
PACKAGE_NAME,
|
|
8
|
+
assetsDirFor,
|
|
9
|
+
packageAssetsDir,
|
|
10
|
+
resolveConfigDir,
|
|
11
|
+
settingsPathFor,
|
|
12
|
+
} from '../src/paths.js';
|
|
13
|
+
import { EVENTS, HOOK_TIMEOUT, buildCommands, soundFromCommand } from '../src/preset.js';
|
|
14
|
+
import { applyPreset, findPreset, removePreset } from '../src/settings.js';
|
|
15
|
+
import {
|
|
16
|
+
backupSettings,
|
|
17
|
+
installAssets,
|
|
18
|
+
readSettings,
|
|
19
|
+
removeAssets,
|
|
20
|
+
writeSettings,
|
|
21
|
+
} from '../src/fsops.js';
|
|
22
|
+
|
|
23
|
+
const USAGE = `Usage: npx ${PACKAGE_NAME} <command> [options]
|
|
24
|
+
|
|
25
|
+
Commands:
|
|
26
|
+
install Add the sound hooks to your Claude Code settings
|
|
27
|
+
uninstall Remove them again and delete the copied sounds
|
|
28
|
+
status Show which hooks are installed right now
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--stop <file> Sound for the Stop hook (Claude finished)
|
|
32
|
+
--notification <file> Sound for the Notification hook
|
|
33
|
+
--permission <file> Sound for the PermissionRequest hook
|
|
34
|
+
--config-dir <path> Claude config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)
|
|
35
|
+
-h, --help Show this message`;
|
|
36
|
+
|
|
37
|
+
const FLAG_TO_EVENT = {
|
|
38
|
+
'--stop': 'Stop',
|
|
39
|
+
'--notification': 'Notification',
|
|
40
|
+
'--permission': 'PermissionRequest',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
class UsageError extends Error {}
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv) {
|
|
46
|
+
const overrides = {};
|
|
47
|
+
let configDir;
|
|
48
|
+
let command;
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
51
|
+
const arg = argv[i];
|
|
52
|
+
|
|
53
|
+
if (arg === '-h' || arg === '--help') {
|
|
54
|
+
command = 'help';
|
|
55
|
+
} else if (arg === '--config-dir' || FLAG_TO_EVENT[arg]) {
|
|
56
|
+
const value = argv[i + 1];
|
|
57
|
+
if (value === undefined || value.startsWith('-')) {
|
|
58
|
+
throw new UsageError(`Option ${arg} needs a value.`);
|
|
59
|
+
}
|
|
60
|
+
if (arg === '--config-dir') configDir = value;
|
|
61
|
+
else overrides[FLAG_TO_EVENT[arg]] = value;
|
|
62
|
+
i += 1;
|
|
63
|
+
} else if (arg.startsWith('-')) {
|
|
64
|
+
throw new UsageError(`Unknown option: ${arg}`);
|
|
65
|
+
} else if (command === undefined) {
|
|
66
|
+
command = arg;
|
|
67
|
+
} else {
|
|
68
|
+
throw new UsageError(`Unexpected argument: ${arg}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { command: command ?? 'help', configDir, overrides };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function install({ configDir, overrides }) {
|
|
76
|
+
const assetsDir = assetsDirFor(configDir);
|
|
77
|
+
const settingsFile = settingsPathFor(configDir);
|
|
78
|
+
|
|
79
|
+
// Validate first: a typo in --stop must not leave assets copied or settings
|
|
80
|
+
// half-written.
|
|
81
|
+
for (const [event, file] of Object.entries(overrides)) {
|
|
82
|
+
if (!fs.existsSync(file)) {
|
|
83
|
+
throw new UsageError(`No such sound file for ${event}: ${file}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const { settings } = readSettings(settingsFile);
|
|
87
|
+
|
|
88
|
+
installAssets(packageAssetsDir(), assetsDir);
|
|
89
|
+
const commands = buildCommands(assetsDir, overrides);
|
|
90
|
+
const backup = backupSettings(settingsFile);
|
|
91
|
+
writeSettings(
|
|
92
|
+
settingsFile,
|
|
93
|
+
applyPreset(settings, { commands, marker: assetsDir, timeout: HOOK_TIMEOUT }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
console.log(`Installed ${PACKAGE_NAME}.`);
|
|
97
|
+
for (const event of EVENTS) {
|
|
98
|
+
console.log(` ${event.padEnd(18)} ${soundFromCommand(commands[event])}`);
|
|
99
|
+
}
|
|
100
|
+
console.log(`\nSounds: ${assetsDir}`);
|
|
101
|
+
console.log(`Settings: ${settingsFile}`);
|
|
102
|
+
if (backup) console.log(`Backup: ${backup}`);
|
|
103
|
+
console.log('\nRestart Claude Code, or start a new session, to pick up the hooks.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function uninstall({ configDir }) {
|
|
107
|
+
const assetsDir = assetsDirFor(configDir);
|
|
108
|
+
const settingsFile = settingsPathFor(configDir);
|
|
109
|
+
|
|
110
|
+
const { exists, settings } = readSettings(settingsFile);
|
|
111
|
+
const removedEvents = Object.keys(findPreset(settings, assetsDir));
|
|
112
|
+
|
|
113
|
+
if (exists) {
|
|
114
|
+
const backup = backupSettings(settingsFile);
|
|
115
|
+
writeSettings(settingsFile, removePreset(settings, assetsDir));
|
|
116
|
+
if (backup && removedEvents.length > 0) console.log(`Backup: ${backup}`);
|
|
117
|
+
}
|
|
118
|
+
removeAssets(assetsDir);
|
|
119
|
+
|
|
120
|
+
if (removedEvents.length > 0) {
|
|
121
|
+
console.log(`Removed hooks: ${removedEvents.join(', ')}`);
|
|
122
|
+
} else {
|
|
123
|
+
console.log(`${PACKAGE_NAME} was not installed; nothing to remove.`);
|
|
124
|
+
}
|
|
125
|
+
console.log('Your other hooks were left alone.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function status({ configDir }) {
|
|
129
|
+
const assetsDir = assetsDirFor(configDir);
|
|
130
|
+
const settingsFile = settingsPathFor(configDir);
|
|
131
|
+
|
|
132
|
+
const { settings } = readSettings(settingsFile);
|
|
133
|
+
const found = findPreset(settings, assetsDir);
|
|
134
|
+
|
|
135
|
+
console.log(`Settings: ${settingsFile}`);
|
|
136
|
+
console.log(`Sounds: ${assetsDir}\n`);
|
|
137
|
+
|
|
138
|
+
if (Object.keys(found).length === 0) {
|
|
139
|
+
console.log(`${PACKAGE_NAME} is not installed. Run: npx ${PACKAGE_NAME} install`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const event of EVENTS) {
|
|
144
|
+
const commands = found[event];
|
|
145
|
+
if (!commands) {
|
|
146
|
+
console.log(` ${event.padEnd(18)} -`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
for (const command of commands) {
|
|
150
|
+
const sound = soundFromCommand(command);
|
|
151
|
+
const missing = sound && !fs.existsSync(sound) ? ' (file missing!)' : '';
|
|
152
|
+
console.log(` ${event.padEnd(18)} ${sound}${missing}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function main(argv) {
|
|
158
|
+
const { command, configDir, overrides } = parseArgs(argv);
|
|
159
|
+
|
|
160
|
+
if (command === 'help') {
|
|
161
|
+
console.log(USAGE);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const commands = { install, uninstall, status };
|
|
166
|
+
const run = commands[command];
|
|
167
|
+
if (!run) throw new UsageError(`Unknown command: ${command}`);
|
|
168
|
+
|
|
169
|
+
assertSupportedPlatform(process.platform);
|
|
170
|
+
run({ configDir: resolveConfigDir(configDir), overrides });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
main(process.argv.slice(2));
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof UsageError) {
|
|
177
|
+
console.error(`${error.message}\n\n${USAGE}`);
|
|
178
|
+
} else {
|
|
179
|
+
console.error(error.message);
|
|
180
|
+
}
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-code-sound-hooks",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "One command to install sound notifications for Claude Code on macOS — hear when Claude finishes, needs permission, or notifies you.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-code-sound-hooks": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"assets",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test \"test/*.test.js\""
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"os": [
|
|
26
|
+
"darwin"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"claude",
|
|
30
|
+
"claude-code",
|
|
31
|
+
"hooks",
|
|
32
|
+
"sound",
|
|
33
|
+
"notification",
|
|
34
|
+
"afplay",
|
|
35
|
+
"macos"
|
|
36
|
+
],
|
|
37
|
+
"author": "victorluu99",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/VictorLuu99/claude-code-sound-hooks.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/VictorLuu99/claude-code-sound-hooks/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/VictorLuu99/claude-code-sound-hooks#readme"
|
|
47
|
+
}
|
package/src/fsops.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { PLAYER_NAME } from './paths.js';
|
|
5
|
+
|
|
6
|
+
const SOUND_EXTENSIONS = /\.(mp3|aiff?|wav|m4a)$/i;
|
|
7
|
+
|
|
8
|
+
/** Thrown when `settings.json` exists but is not valid JSON. */
|
|
9
|
+
export class SettingsParseError extends Error {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads and parses `settings.json`. A missing or empty file counts as `{}`;
|
|
13
|
+
* unparseable content throws, because the caller must not overwrite a file it
|
|
14
|
+
* cannot understand.
|
|
15
|
+
*/
|
|
16
|
+
export function readSettings(file) {
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code === 'ENOENT') return { exists: false, settings: {} };
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (raw.trim() === '') return { exists: true, settings: {} };
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
return { exists: true, settings: JSON.parse(raw) };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new SettingsParseError(`Could not parse ${file}: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Copies the current settings aside. Returns the backup path, or null. */
|
|
35
|
+
export function backupSettings(file) {
|
|
36
|
+
if (!fs.existsSync(file)) return null;
|
|
37
|
+
|
|
38
|
+
const stamp = new Date().toISOString().replaceAll(':', '-');
|
|
39
|
+
const backup = `${file}.backup-${stamp}`;
|
|
40
|
+
fs.copyFileSync(file, backup);
|
|
41
|
+
return backup;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Writes via a temp file and rename, so an interrupted write cannot truncate. */
|
|
45
|
+
export function writeSettings(file, settings) {
|
|
46
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
|
+
|
|
48
|
+
const temp = `${file}.tmp-${process.pid}`;
|
|
49
|
+
fs.writeFileSync(temp, `${JSON.stringify(settings, null, 2)}\n`);
|
|
50
|
+
fs.renameSync(temp, file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Copies the bundled sounds and the wrapper script into the config dir. */
|
|
54
|
+
export function installAssets(sourceDir, targetDir) {
|
|
55
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
const soundsDir = path.join(sourceDir, 'sounds');
|
|
58
|
+
const sounds = fs.readdirSync(soundsDir).filter((name) => SOUND_EXTENSIONS.test(name));
|
|
59
|
+
for (const name of sounds) {
|
|
60
|
+
fs.copyFileSync(path.join(soundsDir, name), path.join(targetDir, name));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const player = path.join(targetDir, PLAYER_NAME);
|
|
64
|
+
fs.copyFileSync(path.join(sourceDir, PLAYER_NAME), player);
|
|
65
|
+
fs.chmodSync(player, 0o755);
|
|
66
|
+
|
|
67
|
+
return [...sounds, PLAYER_NAME];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function removeAssets(dir) {
|
|
71
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { assertSupportedPlatform } from './platform.js';
|
|
2
|
+
export {
|
|
3
|
+
PACKAGE_NAME,
|
|
4
|
+
PLAYER_NAME,
|
|
5
|
+
assetsDirFor,
|
|
6
|
+
packageAssetsDir,
|
|
7
|
+
resolveConfigDir,
|
|
8
|
+
settingsPathFor,
|
|
9
|
+
} from './paths.js';
|
|
10
|
+
export {
|
|
11
|
+
DEFAULT_SOUNDS,
|
|
12
|
+
EVENTS,
|
|
13
|
+
HOOK_TIMEOUT,
|
|
14
|
+
buildCommands,
|
|
15
|
+
resolveSound,
|
|
16
|
+
shellQuote,
|
|
17
|
+
soundFromCommand,
|
|
18
|
+
} from './preset.js';
|
|
19
|
+
export { applyPreset, findPreset, removePreset } from './settings.js';
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
export const PACKAGE_NAME = 'claude-code-sound-hooks';
|
|
6
|
+
export const PLAYER_NAME = 'play.sh';
|
|
7
|
+
|
|
8
|
+
/** The `assets/` directory shipped inside this package. */
|
|
9
|
+
export function packageAssetsDir() {
|
|
10
|
+
return fileURLToPath(new URL('../assets/', import.meta.url));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where Claude Code keeps `settings.json`. `CLAUDE_CONFIG_DIR` wins over the
|
|
15
|
+
* default so people who relocate their config still get a working install.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveConfigDir(override) {
|
|
18
|
+
if (override) return path.resolve(override);
|
|
19
|
+
if (process.env.CLAUDE_CONFIG_DIR) return path.resolve(process.env.CLAUDE_CONFIG_DIR);
|
|
20
|
+
return path.join(os.homedir(), '.claude');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Our own directory under the config dir. Its absolute path doubles as the
|
|
25
|
+
* marker that identifies the hooks we installed, so every hook command we write
|
|
26
|
+
* contains it.
|
|
27
|
+
*/
|
|
28
|
+
export function assetsDirFor(configDir) {
|
|
29
|
+
return path.join(configDir, 'sounds', PACKAGE_NAME);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function settingsPathFor(configDir) {
|
|
33
|
+
return path.join(configDir, 'settings.json');
|
|
34
|
+
}
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* These hooks play sound with `afplay`, which only ships with macOS. Fail loudly
|
|
3
|
+
* rather than writing hooks that can never make a sound.
|
|
4
|
+
*/
|
|
5
|
+
export function assertSupportedPlatform(platform) {
|
|
6
|
+
if (platform !== 'darwin') {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`claude-code-sound-hooks only supports macOS (it plays sound with afplay), but this is "${platform}".`,
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/preset.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { PLAYER_NAME } from './paths.js';
|
|
4
|
+
|
|
5
|
+
/** The Claude Code hook events we attach a sound to. */
|
|
6
|
+
export const EVENTS = ['Stop', 'Notification', 'PermissionRequest'];
|
|
7
|
+
|
|
8
|
+
/** Seconds Claude Code waits for the hook before giving up on it. */
|
|
9
|
+
export const HOOK_TIMEOUT = 10;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `bundled` sounds ship in this package and get copied next to the wrapper.
|
|
13
|
+
* `path` sounds already exist on every Mac, so we point straight at them.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_SOUNDS = {
|
|
16
|
+
Stop: { bundled: 'goi-goi-may-xong-goi.mp3' },
|
|
17
|
+
Notification: { path: '/System/Library/Sounds/Glass.aiff' },
|
|
18
|
+
PermissionRequest: { bundled: 'oi-ban-oi.mp3' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Wraps a value so the shell sees it as one literal argument. */
|
|
22
|
+
export function shellQuote(value) {
|
|
23
|
+
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveSound(event, assetsDir, override) {
|
|
27
|
+
if (override) return path.resolve(override);
|
|
28
|
+
const sound = DEFAULT_SOUNDS[event];
|
|
29
|
+
return sound.bundled ? path.join(assetsDir, sound.bundled) : sound.path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds the shell command for each event. Everything goes through the wrapper
|
|
34
|
+
* script in `assetsDir`, which is what makes an installed hook recognisable as
|
|
35
|
+
* ours later — see `assetsDirFor`.
|
|
36
|
+
*/
|
|
37
|
+
export function buildCommands(assetsDir, overrides = {}) {
|
|
38
|
+
const player = shellQuote(path.join(assetsDir, PLAYER_NAME));
|
|
39
|
+
|
|
40
|
+
const commands = {};
|
|
41
|
+
for (const event of EVENTS) {
|
|
42
|
+
commands[event] = `${player} ${shellQuote(resolveSound(event, assetsDir, overrides[event]))}`;
|
|
43
|
+
}
|
|
44
|
+
return commands;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Recovers the sound path from a command we wrote, for display purposes. */
|
|
48
|
+
export function soundFromCommand(command) {
|
|
49
|
+
const match = /'((?:[^']|'\\'')*)'\s*$/.exec(command);
|
|
50
|
+
return match ? match[1].replaceAll(`'\\''`, "'") : null;
|
|
51
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure transformations of a parsed `settings.json`. Nothing here touches the
|
|
3
|
+
* filesystem, and nothing mutates its argument.
|
|
4
|
+
*
|
|
5
|
+
* A hook is "ours" when its command contains the marker — the absolute path of
|
|
6
|
+
* our assets directory. Everything else in the file is left exactly as found,
|
|
7
|
+
* because users keep their own hooks in there too.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function isPlainObject(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isOurs(hook, marker) {
|
|
15
|
+
return isPlainObject(hook) && typeof hook.command === 'string' && hook.command.includes(marker);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Adds one hook per event. Existing hooks of ours are removed first, so calling
|
|
20
|
+
* this repeatedly leaves a single hook per event instead of a growing pile.
|
|
21
|
+
*/
|
|
22
|
+
export function applyPreset(settings, { commands, marker, timeout }) {
|
|
23
|
+
const base = removePreset(settings, marker);
|
|
24
|
+
const hooks = { ...(isPlainObject(base.hooks) ? base.hooks : {}) };
|
|
25
|
+
|
|
26
|
+
for (const [event, command] of Object.entries(commands)) {
|
|
27
|
+
const groups = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
|
|
28
|
+
const hook = { type: 'command', command };
|
|
29
|
+
if (timeout !== undefined) hook.timeout = timeout;
|
|
30
|
+
groups.push({ hooks: [hook] });
|
|
31
|
+
hooks[event] = groups;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { ...base, hooks };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Removes every hook of ours, pruning containers that end up empty. */
|
|
38
|
+
export function removePreset(settings, marker) {
|
|
39
|
+
const next = structuredClone(settings ?? {});
|
|
40
|
+
if (!isPlainObject(next.hooks)) return next;
|
|
41
|
+
|
|
42
|
+
const hooks = {};
|
|
43
|
+
for (const [event, groups] of Object.entries(next.hooks)) {
|
|
44
|
+
if (!Array.isArray(groups)) {
|
|
45
|
+
hooks[event] = groups;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const keptGroups = [];
|
|
50
|
+
for (const group of groups) {
|
|
51
|
+
const inner = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : null;
|
|
52
|
+
if (!inner || inner.length === 0) {
|
|
53
|
+
keptGroups.push(group);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const kept = inner.filter((hook) => !isOurs(hook, marker));
|
|
58
|
+
if (kept.length === inner.length) keptGroups.push(group);
|
|
59
|
+
else if (kept.length > 0) keptGroups.push({ ...group, hooks: kept });
|
|
60
|
+
// A group that held nothing but our hooks disappears with them.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (keptGroups.length > 0) hooks[event] = keptGroups;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (Object.keys(hooks).length > 0) next.hooks = hooks;
|
|
67
|
+
else delete next.hooks;
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Maps each event to the commands of ours currently installed on it. */
|
|
72
|
+
export function findPreset(settings, marker) {
|
|
73
|
+
const hooks = isPlainObject(settings) && isPlainObject(settings.hooks) ? settings.hooks : {};
|
|
74
|
+
|
|
75
|
+
const found = {};
|
|
76
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
77
|
+
if (!Array.isArray(groups)) continue;
|
|
78
|
+
|
|
79
|
+
const commands = [];
|
|
80
|
+
for (const group of groups) {
|
|
81
|
+
const inner = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
|
|
82
|
+
for (const hook of inner) {
|
|
83
|
+
if (isOurs(hook, marker)) commands.push(hook.command);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (commands.length > 0) found[event] = commands;
|
|
88
|
+
}
|
|
89
|
+
return found;
|
|
90
|
+
}
|