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 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
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
+ }
@@ -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
+ }
@@ -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
+ }