tonton-cli 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 +21 -0
- package/README.md +133 -0
- package/bin/tonton.js +99 -0
- package/lib/index.js +18 -0
- package/lib/index.mjs +6 -0
- package/lib/player.js +129 -0
- package/lib/setup.js +103 -0
- package/lib/sounds.js +248 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tonton contributors
|
|
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,133 @@
|
|
|
1
|
+
# tonton
|
|
2
|
+
|
|
3
|
+
Play a notification sound from your terminal. Zero dependencies. ~15KB.
|
|
4
|
+
|
|
5
|
+
Perfect for getting pinged when AI coding tools (Claude Code, Codex, etc.) finish a task — or need your attention.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx tonton-cli --setup
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That's it. Claude Code will now play:
|
|
14
|
+
- A **done** sound when the agent finishes
|
|
15
|
+
- An **input** sound when the agent needs you
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g tonton-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or use without installing — `npx tonton-cli` works everywhere.
|
|
24
|
+
|
|
25
|
+
## Two Sounds
|
|
26
|
+
|
|
27
|
+
| Command | When | macOS | Cross-platform |
|
|
28
|
+
|---------|------|-------|---------------|
|
|
29
|
+
| `tonton --done` | Agent finished | Glass | Descending chime |
|
|
30
|
+
| `tonton --input` | Agent needs you | Funk | Ascending chime |
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
tonton --done # hear the "finished" sound
|
|
34
|
+
tonton --input # hear the "need attention" sound
|
|
35
|
+
tonton # default notification sound
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Use with AI CLI Tools
|
|
39
|
+
|
|
40
|
+
### Claude Code — automatic setup
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx tonton-cli --setup
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This adds hooks to `~/.claude/settings.json` so Claude Code plays distinct sounds when done vs. needing input. Run once, works forever.
|
|
47
|
+
|
|
48
|
+
To remove: `npx tonton-cli --remove`
|
|
49
|
+
|
|
50
|
+
### Claude Code — manual setup
|
|
51
|
+
|
|
52
|
+
Add to `~/.claude/settings.json`:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"hooks": {
|
|
57
|
+
"AfterAssistantTurn": [
|
|
58
|
+
{ "type": "command", "command": "npx tonton-cli --done" }
|
|
59
|
+
],
|
|
60
|
+
"Notification": [
|
|
61
|
+
{ "type": "command", "command": "npx tonton-cli --input" }
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Codex CLI
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
codex "fix the bug" ; npx tonton-cli --done
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Any command
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm test ; npx tonton-cli --done
|
|
77
|
+
long-running-task ; npx tonton-cli
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Shell alias
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Add to ~/.zshrc or ~/.bashrc
|
|
84
|
+
notify() { "$@"; npx tonton-cli --done; }
|
|
85
|
+
|
|
86
|
+
# Then use:
|
|
87
|
+
notify npm test
|
|
88
|
+
notify codex "refactor this"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## More Options
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Pick a specific sound (macOS system sounds)
|
|
95
|
+
tonton --sound Glass
|
|
96
|
+
tonton --sound Hero
|
|
97
|
+
|
|
98
|
+
# Adjust volume (macOS only)
|
|
99
|
+
tonton --volume 0.5
|
|
100
|
+
|
|
101
|
+
# List available sounds
|
|
102
|
+
tonton --list
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Programmatic API
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
import { play, listSounds } from 'tonton-cli';
|
|
109
|
+
|
|
110
|
+
await play(); // default sound
|
|
111
|
+
await play({ sound: 'done' }); // completion sound
|
|
112
|
+
await play({ sound: 'input' }); // attention sound
|
|
113
|
+
await play({ sound: 'Glass' }); // macOS system sound
|
|
114
|
+
await play({ volume: 0.5 }); // half volume (macOS)
|
|
115
|
+
|
|
116
|
+
const sounds = listSounds(); // available sound names
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Cross-Platform
|
|
120
|
+
|
|
121
|
+
| Platform | Player | Sounds |
|
|
122
|
+
|----------|--------|--------|
|
|
123
|
+
| **macOS** | `afplay` (built-in) | 14 system sounds + synthesized chime |
|
|
124
|
+
| **Linux** | `paplay` / `aplay` / `ffplay` | Freedesktop sounds + synthesized chime |
|
|
125
|
+
| **Windows** | PowerShell SoundPlayer | Windows Media sounds + synthesized chime |
|
|
126
|
+
|
|
127
|
+
Falls back to a synthesized two-tone chime, then terminal bell — always plays something.
|
|
128
|
+
|
|
129
|
+
**Always exits 0.** A notification sound will never break your scripts, hooks, or CI.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
package/bin/tonton.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { play, listSounds } = require('../lib/index');
|
|
5
|
+
const { version } = require('../package.json');
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
let sound = undefined;
|
|
9
|
+
let vol = undefined;
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
const arg = args[i];
|
|
13
|
+
|
|
14
|
+
if (arg === '-h' || arg === '--help') {
|
|
15
|
+
console.log(`tonton v${version} — notification sound for your terminal
|
|
16
|
+
|
|
17
|
+
Usage: tonton [options]
|
|
18
|
+
|
|
19
|
+
Setup:
|
|
20
|
+
--setup Auto-configure Claude Code hooks (one-time)
|
|
21
|
+
--remove Remove tonton hooks from Claude Code
|
|
22
|
+
|
|
23
|
+
Presets:
|
|
24
|
+
-d, --done Agent finished (descending chime)
|
|
25
|
+
-i, --input Agent needs input (ascending chime)
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
-s, --sound <name> Sound to play (default: "Ping" on macOS, "chime" elsewhere)
|
|
29
|
+
-v, --volume <0-1> Volume level, 0.0 to 1.0 (macOS only, default: 1.0)
|
|
30
|
+
-l, --list List available sounds
|
|
31
|
+
-h, --help Show this help
|
|
32
|
+
--version Show version
|
|
33
|
+
|
|
34
|
+
Quick start:
|
|
35
|
+
npx tonton-cli --setup Configure Claude Code hooks automatically
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
npx tonton-cli Play default notification sound
|
|
39
|
+
npx tonton-cli --done Agent finished working
|
|
40
|
+
npx tonton-cli --input Agent needs your attention
|
|
41
|
+
npx tonton-cli -s Glass Play the Glass sound (macOS)
|
|
42
|
+
|
|
43
|
+
After any command:
|
|
44
|
+
codex "fix the bug" ; npx tonton-cli --done`);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (arg === '--version') {
|
|
49
|
+
console.log(version);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (arg === '--setup') {
|
|
54
|
+
console.log('Setting up Claude Code hooks...\n');
|
|
55
|
+
require('../lib/setup').setup();
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (arg === '--remove') {
|
|
60
|
+
console.log('Removing tonton hooks...\n');
|
|
61
|
+
require('../lib/setup').unsetup();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (arg === '-l' || arg === '--list') {
|
|
66
|
+
const sounds = listSounds();
|
|
67
|
+
console.log('Available sounds:');
|
|
68
|
+
for (const s of sounds) {
|
|
69
|
+
console.log(' ' + s);
|
|
70
|
+
}
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (arg === '-d' || arg === '--done') {
|
|
75
|
+
sound = 'done';
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (arg === '-i' || arg === '--input') {
|
|
80
|
+
sound = 'input';
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if ((arg === '-s' || arg === '--sound') && i + 1 < args.length) {
|
|
85
|
+
sound = args[++i];
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ((arg === '-v' || arg === '--volume') && i + 1 < args.length) {
|
|
90
|
+
vol = parseFloat(args[++i]);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
play({ sound, volume: vol }).then(() => {
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}).catch(() => {
|
|
98
|
+
process.exit(0);
|
|
99
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveSound, listSounds, generateWav } = require('./sounds');
|
|
4
|
+
const { playSound } = require('./player');
|
|
5
|
+
|
|
6
|
+
async function play(options = {}) {
|
|
7
|
+
const { sound = 'default', volume } = options;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const soundInfo = resolveSound(sound);
|
|
11
|
+
await playSound(soundInfo, { volume });
|
|
12
|
+
} catch {
|
|
13
|
+
// Never throw — a notification sound failing is not an error
|
|
14
|
+
try { process.stdout.write('\x07'); } catch {}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { play, listSounds, generateWav };
|
package/lib/index.mjs
ADDED
package/lib/player.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFile, exec } = require('child_process');
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const TIMEOUT = 5000;
|
|
8
|
+
let cachedLinuxPlayer = undefined; // undefined = not detected yet
|
|
9
|
+
|
|
10
|
+
function findLinuxPlayer() {
|
|
11
|
+
if (cachedLinuxPlayer !== undefined) return cachedLinuxPlayer;
|
|
12
|
+
|
|
13
|
+
const players = [
|
|
14
|
+
{ cmd: 'paplay', args: (f) => [f] },
|
|
15
|
+
{ cmd: 'pw-play', args: (f) => [f] },
|
|
16
|
+
{ cmd: 'aplay', args: (f) => [f], wavOnly: true },
|
|
17
|
+
{ cmd: 'ffplay', args: (f) => ['-nodisp', '-autoexit', '-loglevel', 'quiet', f] },
|
|
18
|
+
{ cmd: 'play', args: (f) => ['-q', f] },
|
|
19
|
+
{ cmd: 'mpg123', args: (f) => ['-q', f] },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
for (const player of players) {
|
|
23
|
+
try {
|
|
24
|
+
execFileSync('which', [player.cmd], { stdio: 'ignore' });
|
|
25
|
+
cachedLinuxPlayer = player;
|
|
26
|
+
return player;
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
cachedLinuxPlayer = null;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanup(soundInfo) {
|
|
35
|
+
if (soundInfo.cleanup && soundInfo.filePath) {
|
|
36
|
+
fs.unlink(soundInfo.filePath, () => {}); // ignore errors
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function playDarwin(soundInfo, options = {}) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const args = [soundInfo.filePath];
|
|
43
|
+
if (options.volume != null) {
|
|
44
|
+
args.push('-v', String(options.volume));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const child = execFile('afplay', args, { timeout: TIMEOUT }, (err) => {
|
|
48
|
+
cleanup(soundInfo);
|
|
49
|
+
resolve();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
child.on('error', () => {
|
|
53
|
+
cleanup(soundInfo);
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function playLinux(soundInfo, options = {}) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const player = findLinuxPlayer();
|
|
62
|
+
|
|
63
|
+
if (!player) {
|
|
64
|
+
cleanup(soundInfo);
|
|
65
|
+
bellFallback();
|
|
66
|
+
resolve();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If player only supports wav but file isn't wav, need synthesized wav
|
|
71
|
+
if (player.wavOnly && !soundInfo.filePath.endsWith('.wav')) {
|
|
72
|
+
const { synthesizeToTemp } = require('./sounds');
|
|
73
|
+
cleanup(soundInfo);
|
|
74
|
+
soundInfo = synthesizeToTemp();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const args = player.args(soundInfo.filePath);
|
|
78
|
+
const child = execFile(player.cmd, args, { timeout: TIMEOUT }, (err) => {
|
|
79
|
+
cleanup(soundInfo);
|
|
80
|
+
resolve();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
child.on('error', () => {
|
|
84
|
+
cleanup(soundInfo);
|
|
85
|
+
bellFallback();
|
|
86
|
+
resolve();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function playWindows(soundInfo, options = {}) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const escaped = soundInfo.filePath.replace(/'/g, "''");
|
|
94
|
+
const cmd = `powershell -Command "(New-Object System.Media.SoundPlayer '${escaped}').PlaySync()"`;
|
|
95
|
+
|
|
96
|
+
exec(cmd, { timeout: TIMEOUT }, (err) => {
|
|
97
|
+
cleanup(soundInfo);
|
|
98
|
+
if (err) {
|
|
99
|
+
// Fallback to Console.Beep
|
|
100
|
+
exec('powershell -Command "[System.Console]::Beep(880, 300)"', { timeout: TIMEOUT }, () => {
|
|
101
|
+
resolve();
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
resolve();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bellFallback() {
|
|
111
|
+
try {
|
|
112
|
+
process.stdout.write('\x07');
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function playSound(soundInfo, options = {}) {
|
|
117
|
+
const platform = process.platform;
|
|
118
|
+
|
|
119
|
+
if (platform === 'darwin') return playDarwin(soundInfo, options);
|
|
120
|
+
if (platform === 'linux') return playLinux(soundInfo, options);
|
|
121
|
+
if (platform === 'win32') return playWindows(soundInfo, options);
|
|
122
|
+
|
|
123
|
+
// Unknown platform
|
|
124
|
+
cleanup(soundInfo);
|
|
125
|
+
bellFallback();
|
|
126
|
+
return Promise.resolve();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { playSound };
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
8
|
+
|
|
9
|
+
const HOOKS = {
|
|
10
|
+
Notification: { type: 'command', command: 'npx tonton-cli --input' },
|
|
11
|
+
AfterAssistantTurn: { type: 'command', command: 'npx tonton-cli --done' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function readSettings() {
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
|
|
17
|
+
return JSON.parse(raw);
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeSettings(settings) {
|
|
24
|
+
const dir = path.dirname(CLAUDE_SETTINGS_PATH);
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hookExists(hooks, newHook) {
|
|
30
|
+
return hooks.some(h => h.command === newHook.command);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setup() {
|
|
34
|
+
const settings = readSettings();
|
|
35
|
+
|
|
36
|
+
if (!settings.hooks) settings.hooks = {};
|
|
37
|
+
|
|
38
|
+
let added = 0;
|
|
39
|
+
|
|
40
|
+
for (const [event, hook] of Object.entries(HOOKS)) {
|
|
41
|
+
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
42
|
+
|
|
43
|
+
if (!hookExists(settings.hooks[event], hook)) {
|
|
44
|
+
settings.hooks[event].push(hook);
|
|
45
|
+
added++;
|
|
46
|
+
console.log(` + ${event} → ${hook.command}`);
|
|
47
|
+
} else {
|
|
48
|
+
console.log(` ~ ${event} → already configured`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (added > 0) {
|
|
53
|
+
writeSettings(settings);
|
|
54
|
+
console.log(`\nWrote ${CLAUDE_SETTINGS_PATH}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log('\nDone! Claude Code will now play:');
|
|
58
|
+
console.log(' --done sound when the agent finishes');
|
|
59
|
+
console.log(' --input sound when the agent needs you');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function unsetup() {
|
|
63
|
+
const settings = readSettings();
|
|
64
|
+
|
|
65
|
+
if (!settings.hooks) {
|
|
66
|
+
console.log('No hooks found — nothing to remove.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let removed = 0;
|
|
71
|
+
|
|
72
|
+
for (const [event, hook] of Object.entries(HOOKS)) {
|
|
73
|
+
if (!settings.hooks[event]) continue;
|
|
74
|
+
|
|
75
|
+
const before = settings.hooks[event].length;
|
|
76
|
+
settings.hooks[event] = settings.hooks[event].filter(h => h.command !== hook.command);
|
|
77
|
+
const after = settings.hooks[event].length;
|
|
78
|
+
|
|
79
|
+
if (before !== after) {
|
|
80
|
+
removed++;
|
|
81
|
+
console.log(` - ${event} → removed`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Clean up empty arrays
|
|
85
|
+
if (settings.hooks[event].length === 0) {
|
|
86
|
+
delete settings.hooks[event];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Clean up empty hooks object
|
|
91
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
92
|
+
delete settings.hooks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (removed > 0) {
|
|
96
|
+
writeSettings(settings);
|
|
97
|
+
console.log(`\nWrote ${CLAUDE_SETTINGS_PATH}`);
|
|
98
|
+
} else {
|
|
99
|
+
console.log('No tonton hooks found — nothing to remove.');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { setup, unsetup };
|
package/lib/sounds.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const MACOS_SOUNDS_DIR = '/System/Library/Sounds';
|
|
8
|
+
const FREEDESKTOP_DIRS = [
|
|
9
|
+
'/usr/share/sounds/freedesktop/stereo',
|
|
10
|
+
'/usr/share/sounds/gnome/default/alerts',
|
|
11
|
+
'/usr/share/sounds/ubuntu/stereo',
|
|
12
|
+
];
|
|
13
|
+
const WINDOWS_SOUNDS_DIR = 'C:\\Windows\\Media';
|
|
14
|
+
|
|
15
|
+
// Presets: distinct sounds for different events
|
|
16
|
+
const PRESETS = {
|
|
17
|
+
done: {
|
|
18
|
+
// Descending two-tone: satisfying "complete" feel
|
|
19
|
+
macos: 'Glass',
|
|
20
|
+
synth: { tones: [{ freq: 880, start: 0 }, { freq: 660, start: 0.15 }], duration: 0.35 },
|
|
21
|
+
},
|
|
22
|
+
input: {
|
|
23
|
+
// Ascending two-tone: questioning "hey, look here" feel
|
|
24
|
+
macos: 'Funk',
|
|
25
|
+
synth: { tones: [{ freq: 440, start: 0 }, { freq: 880, start: 0.12 }], duration: 0.4 },
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function generateWav(options = {}) {
|
|
30
|
+
const {
|
|
31
|
+
frequency = 880,
|
|
32
|
+
frequency2 = 1320,
|
|
33
|
+
duration = 0.3,
|
|
34
|
+
sampleRate = 8000,
|
|
35
|
+
volume = 0.8,
|
|
36
|
+
tones = null,
|
|
37
|
+
} = options;
|
|
38
|
+
|
|
39
|
+
const numSamples = Math.floor(sampleRate * (tones ? duration : duration));
|
|
40
|
+
const dataSize = numSamples * 2; // 16-bit mono
|
|
41
|
+
const fileSize = 44 + dataSize;
|
|
42
|
+
|
|
43
|
+
const buf = Buffer.alloc(fileSize);
|
|
44
|
+
|
|
45
|
+
// RIFF header
|
|
46
|
+
buf.write('RIFF', 0);
|
|
47
|
+
buf.writeUInt32LE(fileSize - 8, 4);
|
|
48
|
+
buf.write('WAVE', 8);
|
|
49
|
+
|
|
50
|
+
// fmt chunk
|
|
51
|
+
buf.write('fmt ', 12);
|
|
52
|
+
buf.writeUInt32LE(16, 16); // chunk size
|
|
53
|
+
buf.writeUInt16LE(1, 20); // PCM format
|
|
54
|
+
buf.writeUInt16LE(1, 22); // mono
|
|
55
|
+
buf.writeUInt32LE(sampleRate, 24);
|
|
56
|
+
buf.writeUInt32LE(sampleRate * 2, 28); // byte rate
|
|
57
|
+
buf.writeUInt16LE(2, 32); // block align
|
|
58
|
+
buf.writeUInt16LE(16, 34); // bits per sample
|
|
59
|
+
|
|
60
|
+
// data chunk
|
|
61
|
+
buf.write('data', 36);
|
|
62
|
+
buf.writeUInt32LE(dataSize, 40);
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < numSamples; i++) {
|
|
65
|
+
const t = i / sampleRate;
|
|
66
|
+
let sample;
|
|
67
|
+
|
|
68
|
+
if (tones) {
|
|
69
|
+
// Multi-tone mode: each tone starts at a given time offset
|
|
70
|
+
sample = 0;
|
|
71
|
+
for (const tone of tones) {
|
|
72
|
+
const tLocal = t - tone.start;
|
|
73
|
+
if (tLocal >= 0) {
|
|
74
|
+
const decay = Math.exp(-tLocal * 12);
|
|
75
|
+
sample += Math.sin(2 * Math.PI * tone.freq * tLocal) * decay;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
sample = sample / tones.length * volume;
|
|
79
|
+
} else {
|
|
80
|
+
// Default: two simultaneous tones with decay
|
|
81
|
+
const decay = Math.exp(-t * 10);
|
|
82
|
+
const s1 = Math.sin(2 * Math.PI * frequency * t);
|
|
83
|
+
const s2 = Math.sin(2 * Math.PI * frequency2 * t) * 0.6;
|
|
84
|
+
sample = (s1 + s2) * decay * volume;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const value = Math.max(-1, Math.min(1, sample));
|
|
88
|
+
buf.writeInt16LE(Math.floor(value * 32767), 44 + i * 2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return buf;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function synthesizePreset(presetName) {
|
|
95
|
+
const preset = PRESETS[presetName];
|
|
96
|
+
if (!preset) return null;
|
|
97
|
+
const wavBuf = generateWav(preset.synth);
|
|
98
|
+
const tmpPath = path.join(os.tmpdir(), 'tonton-' + presetName + '-' + process.pid + '.wav');
|
|
99
|
+
fs.writeFileSync(tmpPath, wavBuf);
|
|
100
|
+
return { filePath: tmpPath, cleanup: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveMacosSound(name) {
|
|
104
|
+
if (name === 'default' || name === 'chime') name = 'Ping';
|
|
105
|
+
|
|
106
|
+
// Try exact match first
|
|
107
|
+
const filePath = path.join(MACOS_SOUNDS_DIR, name + '.aiff');
|
|
108
|
+
if (fs.existsSync(filePath)) {
|
|
109
|
+
return { filePath, cleanup: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Try case-insensitive
|
|
113
|
+
try {
|
|
114
|
+
const files = fs.readdirSync(MACOS_SOUNDS_DIR);
|
|
115
|
+
const match = files.find(f => f.toLowerCase() === (name + '.aiff').toLowerCase());
|
|
116
|
+
if (match) {
|
|
117
|
+
return { filePath: path.join(MACOS_SOUNDS_DIR, match), cleanup: false };
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveLinuxSound(name) {
|
|
125
|
+
if (name !== 'default' && name !== 'chime') {
|
|
126
|
+
// Try to find a named sound in freedesktop dirs
|
|
127
|
+
for (const dir of FREEDESKTOP_DIRS) {
|
|
128
|
+
for (const ext of ['.oga', '.ogg', '.wav']) {
|
|
129
|
+
const filePath = path.join(dir, name + ext);
|
|
130
|
+
if (fs.existsSync(filePath)) {
|
|
131
|
+
return { filePath, cleanup: false };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Try default freedesktop sounds
|
|
138
|
+
const defaults = ['complete.oga', 'bell.oga', 'message-new-instant.oga', 'message.oga'];
|
|
139
|
+
for (const dir of FREEDESKTOP_DIRS) {
|
|
140
|
+
for (const file of defaults) {
|
|
141
|
+
const filePath = path.join(dir, file);
|
|
142
|
+
if (fs.existsSync(filePath)) {
|
|
143
|
+
return { filePath, cleanup: false };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolveWindowsSound(name) {
|
|
152
|
+
if (name !== 'default' && name !== 'chime') {
|
|
153
|
+
const filePath = path.join(WINDOWS_SOUNDS_DIR, name + '.wav');
|
|
154
|
+
if (fs.existsSync(filePath)) {
|
|
155
|
+
return { filePath, cleanup: false };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const defaults = ['notify.wav', 'chimes.wav', 'tada.wav', 'Windows Notify System Generic.wav'];
|
|
160
|
+
for (const file of defaults) {
|
|
161
|
+
const filePath = path.join(WINDOWS_SOUNDS_DIR, file);
|
|
162
|
+
if (fs.existsSync(filePath)) {
|
|
163
|
+
return { filePath, cleanup: false };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function synthesizeToTemp() {
|
|
171
|
+
const wavBuf = generateWav();
|
|
172
|
+
const tmpPath = path.join(os.tmpdir(), 'tonton-chime-' + process.pid + '.wav');
|
|
173
|
+
fs.writeFileSync(tmpPath, wavBuf);
|
|
174
|
+
return { filePath: tmpPath, cleanup: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function resolveSound(name = 'default') {
|
|
178
|
+
const platform = process.platform;
|
|
179
|
+
const preset = PRESETS[name];
|
|
180
|
+
|
|
181
|
+
// If it's a preset (done/input), use platform-specific sound or synth
|
|
182
|
+
if (preset) {
|
|
183
|
+
if (platform === 'darwin') {
|
|
184
|
+
return resolveMacosSound(preset.macos) || synthesizePreset(name);
|
|
185
|
+
}
|
|
186
|
+
return synthesizePreset(name);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (platform === 'darwin') {
|
|
190
|
+
return resolveMacosSound(name) || synthesizeToTemp();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (platform === 'linux') {
|
|
194
|
+
return resolveLinuxSound(name) || synthesizeToTemp();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (platform === 'win32') {
|
|
198
|
+
return resolveWindowsSound(name) || synthesizeToTemp();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Unknown platform — synthesize
|
|
202
|
+
return synthesizeToTemp();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function listSounds() {
|
|
206
|
+
const platform = process.platform;
|
|
207
|
+
const sounds = [];
|
|
208
|
+
|
|
209
|
+
if (platform === 'darwin') {
|
|
210
|
+
try {
|
|
211
|
+
const files = fs.readdirSync(MACOS_SOUNDS_DIR);
|
|
212
|
+
for (const f of files) {
|
|
213
|
+
if (f.endsWith('.aiff')) {
|
|
214
|
+
sounds.push(f.replace('.aiff', ''));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} catch {}
|
|
218
|
+
} else if (platform === 'linux') {
|
|
219
|
+
for (const dir of FREEDESKTOP_DIRS) {
|
|
220
|
+
try {
|
|
221
|
+
const files = fs.readdirSync(dir);
|
|
222
|
+
for (const f of files) {
|
|
223
|
+
const name = f.replace(/\.(oga|ogg|wav)$/, '');
|
|
224
|
+
if (name !== f && !sounds.includes(name)) {
|
|
225
|
+
sounds.push(name);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
} else if (platform === 'win32') {
|
|
231
|
+
try {
|
|
232
|
+
const files = fs.readdirSync(WINDOWS_SOUNDS_DIR);
|
|
233
|
+
for (const f of files) {
|
|
234
|
+
if (f.endsWith('.wav')) {
|
|
235
|
+
sounds.push(f.replace('.wav', ''));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!sounds.includes('chime')) {
|
|
242
|
+
sounds.push('chime');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return sounds.sort();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { resolveSound, listSounds, generateWav };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tonton-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Play a notification sound from the CLI. Zero dependencies. Perfect for AI coding tool hooks.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"notification",
|
|
7
|
+
"sound",
|
|
8
|
+
"beep",
|
|
9
|
+
"alert",
|
|
10
|
+
"cli",
|
|
11
|
+
"claude-code",
|
|
12
|
+
"codex",
|
|
13
|
+
"hook",
|
|
14
|
+
"bell",
|
|
15
|
+
"ding"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/kyrolloszakaria/tonton.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/kyrolloszakaria/tonton#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/kyrolloszakaria/tonton/issues"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"tonton": "bin/tonton.js"
|
|
28
|
+
},
|
|
29
|
+
"main": "./lib/index.js",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": "./lib/index.mjs",
|
|
33
|
+
"require": "./lib/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin/",
|
|
38
|
+
"lib/",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=14.0.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node test/test.js"
|
|
47
|
+
}
|
|
48
|
+
}
|