klaudio 0.10.3 → 0.11.1
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/bin/cli.js +11 -0
- package/package.json +1 -1
- package/src/cli.js +65 -10
- package/src/installer.js +55 -2
- package/src/notify.js +135 -0
- package/src/player.js +27 -2
- package/src/tts.js +10 -0
package/bin/cli.js
CHANGED
|
@@ -7,6 +7,17 @@ if (process.argv[2] === "play") {
|
|
|
7
7
|
process.exit(0);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
// Subcommand: klaudio notify "title" "body"
|
|
11
|
+
if (process.argv[2] === "notify") {
|
|
12
|
+
const title = process.argv[3] || "klaudio";
|
|
13
|
+
const body = process.argv[4] || "";
|
|
14
|
+
if (body) {
|
|
15
|
+
const { sendNotification } = await import("../src/notify.js");
|
|
16
|
+
await sendNotification(title, body);
|
|
17
|
+
}
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
// Subcommand: klaudio say "text" [--voice <voice>]
|
|
11
22
|
if (process.argv[2] === "say") {
|
|
12
23
|
const args = process.argv.slice(3);
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,10 +2,10 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
|
|
2
2
|
import { render, Box, Text, useInput, useApp } from "ink";
|
|
3
3
|
import Spinner from "ink-spinner";
|
|
4
4
|
import { PRESETS, EVENTS } from "./presets.js";
|
|
5
|
-
import { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE } from "./tts.js";
|
|
5
|
+
import { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, isKokoroAvailable } from "./tts.js";
|
|
6
6
|
import { playSoundWithCancel, getWavDuration } from "./player.js";
|
|
7
7
|
import { getAvailableGames, getSystemSounds } from "./scanner.js";
|
|
8
|
-
import { install, uninstall, getExistingSounds } from "./installer.js";
|
|
8
|
+
import { install, uninstall, getExistingSounds, checkHooksOutdated } from "./installer.js";
|
|
9
9
|
import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
|
|
10
10
|
import { extractUnityResource } from "./unity.js";
|
|
11
11
|
import { extractBunFile, isBunFile } from "./scumm.js";
|
|
@@ -17,6 +17,17 @@ import { join } from "node:path";
|
|
|
17
17
|
const MAX_PLAY_SECONDS = 10;
|
|
18
18
|
const ACCENT = "#76C41E"; // Needle green-yellow midpoint
|
|
19
19
|
|
|
20
|
+
/** Truncate a filename: keep first 10 chars + ext (max 4 chars) */
|
|
21
|
+
function shortName(filePath) {
|
|
22
|
+
const name = basename(filePath);
|
|
23
|
+
const dot = name.lastIndexOf(".");
|
|
24
|
+
if (dot <= 0 || name.length <= 18) return name;
|
|
25
|
+
const stem = name.slice(0, dot);
|
|
26
|
+
const ext = name.slice(dot);
|
|
27
|
+
if (stem.length <= 10) return name;
|
|
28
|
+
return stem.slice(0, 10) + "..." + ext;
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
const h = React.createElement;
|
|
21
32
|
|
|
22
33
|
// ── Custom SelectInput components (bright colors for CMD) ────
|
|
@@ -126,7 +137,7 @@ const NavHint = ({ back = true, extra = "" }) =>
|
|
|
126
137
|
);
|
|
127
138
|
|
|
128
139
|
// ── Screen: Scope ───────────────────────────────────────────────
|
|
129
|
-
const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
|
|
140
|
+
const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts, outdatedReasons }) => {
|
|
130
141
|
const items = [
|
|
131
142
|
{ label: "Global — Claude Code + Copilot (all projects)", value: "global" },
|
|
132
143
|
{ label: "This project — Claude Code + Copilot (this project only)", value: "project" },
|
|
@@ -150,7 +161,17 @@ const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
|
|
|
150
161
|
}
|
|
151
162
|
});
|
|
152
163
|
|
|
164
|
+
const isOutdated = outdatedReasons && outdatedReasons.length > 0;
|
|
165
|
+
|
|
153
166
|
return h(Box, { flexDirection: "column" },
|
|
167
|
+
isOutdated
|
|
168
|
+
? h(Box, { flexDirection: "column", marginLeft: 2, marginBottom: 1 },
|
|
169
|
+
h(Text, { color: "yellow", bold: true }, " Updates available — re-apply to enable:"),
|
|
170
|
+
...outdatedReasons.map((r, i) =>
|
|
171
|
+
h(Text, { key: i, color: "yellow", dimColor: true, marginLeft: 4 }, `+ ${r}`),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
: null,
|
|
154
175
|
h(Text, { bold: true }, " Where should sounds be installed?"),
|
|
155
176
|
h(Box, { flexDirection: "column", marginLeft: 2 },
|
|
156
177
|
...items.map((item, i) => h(React.Fragment, { key: item.value },
|
|
@@ -171,11 +192,14 @@ const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
|
|
|
171
192
|
};
|
|
172
193
|
|
|
173
194
|
// ── Screen: Preset ──────────────────────────────────────────────
|
|
174
|
-
const PresetScreen = ({ existingSounds, onNext, onReapply, onBack }) => {
|
|
195
|
+
const PresetScreen = ({ existingSounds, outdatedReasons, onNext, onReapply, onBack }) => {
|
|
175
196
|
const hasExisting = existingSounds && Object.values(existingSounds).some(Boolean);
|
|
197
|
+
const isOutdated = outdatedReasons && outdatedReasons.length > 0;
|
|
176
198
|
const items = [
|
|
177
199
|
...(hasExisting ? [{
|
|
178
|
-
label:
|
|
200
|
+
label: isOutdated
|
|
201
|
+
? "⬆ Update hooks — new features available for your current sounds"
|
|
202
|
+
: "✓ Re-apply current sounds — update config with current selections",
|
|
179
203
|
value: "_reapply",
|
|
180
204
|
}] : []),
|
|
181
205
|
...Object.entries(PRESETS).map(([id, p]) => ({
|
|
@@ -206,8 +230,16 @@ const PresetScreen = ({ existingSounds, onNext, onReapply, onBack }) => {
|
|
|
206
230
|
? h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
|
|
207
231
|
h(Text, { dimColor: true }, "Current sounds:"),
|
|
208
232
|
...Object.entries(existingSounds).filter(([_, p]) => p).map(([eid, p]) =>
|
|
209
|
-
h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${
|
|
233
|
+
h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${shortName(p)}`),
|
|
210
234
|
),
|
|
235
|
+
isOutdated
|
|
236
|
+
? h(Box, { flexDirection: "column", marginTop: 1 },
|
|
237
|
+
h(Text, { color: "yellow" }, " Updates available:"),
|
|
238
|
+
...outdatedReasons.map((r, i) =>
|
|
239
|
+
h(Text, { key: i, color: "yellow", dimColor: true }, ` + ${r}`),
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
: null,
|
|
211
243
|
)
|
|
212
244
|
: null,
|
|
213
245
|
h(Box, { flexDirection: "column", marginLeft: 2 },
|
|
@@ -1370,11 +1402,11 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
|
|
|
1370
1402
|
};
|
|
1371
1403
|
|
|
1372
1404
|
// ── Screen: Confirm ─────────────────────────────────────────────
|
|
1373
|
-
const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
|
|
1405
|
+
const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
|
|
1374
1406
|
useInput((input, key) => {
|
|
1375
1407
|
if (key.escape) onBack();
|
|
1376
1408
|
else if (input === "t") onToggleTts();
|
|
1377
|
-
else if (input === "v" && tts) onCycleVoice();
|
|
1409
|
+
else if (input === "v" && tts && hasKokoro) onCycleVoice();
|
|
1378
1410
|
});
|
|
1379
1411
|
|
|
1380
1412
|
const items = [
|
|
@@ -1383,7 +1415,7 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, o
|
|
|
1383
1415
|
];
|
|
1384
1416
|
|
|
1385
1417
|
const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
|
|
1386
|
-
const voiceInfo = KOKORO_VOICES.find((v) => v.id === voice);
|
|
1418
|
+
const voiceInfo = hasKokoro ? KOKORO_VOICES.find((v) => v.id === voice) : null;
|
|
1387
1419
|
|
|
1388
1420
|
return h(Box, { flexDirection: "column" },
|
|
1389
1421
|
h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
|
|
@@ -1391,7 +1423,7 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, o
|
|
|
1391
1423
|
h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code + Copilot)" : "This project (Claude Code + Copilot)"}`),
|
|
1392
1424
|
...soundEntries.map(([eid, path]) =>
|
|
1393
1425
|
h(Text, { key: eid, marginLeft: 4 },
|
|
1394
|
-
`${EVENTS[eid].name} → ${
|
|
1426
|
+
`${EVENTS[eid].name} → ${shortName(path)}`
|
|
1395
1427
|
)
|
|
1396
1428
|
),
|
|
1397
1429
|
h(Box, { marginLeft: 4, marginTop: 1 },
|
|
@@ -1530,10 +1562,28 @@ const InstallApp = () => {
|
|
|
1530
1562
|
const [installResult, setInstallResult] = useState(null);
|
|
1531
1563
|
const [tts, setTts] = useState(true);
|
|
1532
1564
|
const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
|
|
1565
|
+
const [hasKokoro, setHasKokoro] = useState(false);
|
|
1566
|
+
const [outdatedReasons, setOutdatedReasons] = useState([]);
|
|
1533
1567
|
const [musicFiles, setMusicFiles] = useState([]);
|
|
1534
1568
|
const [musicGameName, setMusicGameName] = useState(null);
|
|
1535
1569
|
const [musicShuffle, setMusicShuffle] = useState(false);
|
|
1536
1570
|
|
|
1571
|
+
useEffect(() => {
|
|
1572
|
+
isKokoroAvailable().then(setHasKokoro).catch(() => {});
|
|
1573
|
+
// Check both scopes for outdated hooks on startup
|
|
1574
|
+
Promise.all([
|
|
1575
|
+
checkHooksOutdated("global"),
|
|
1576
|
+
checkHooksOutdated("project"),
|
|
1577
|
+
]).then(([g, p]) => {
|
|
1578
|
+
const combined = [...new Set([...g, ...p])];
|
|
1579
|
+
setOutdatedReasons(combined);
|
|
1580
|
+
}).catch(() => {});
|
|
1581
|
+
// Also pre-load existing sounds from global (most common)
|
|
1582
|
+
getExistingSounds("global").then((existing) => {
|
|
1583
|
+
if (Object.keys(existing).length > 0) setSounds(existing);
|
|
1584
|
+
}).catch(() => {});
|
|
1585
|
+
}, []);
|
|
1586
|
+
|
|
1537
1587
|
const initSoundsFromPreset = useCallback((pid) => {
|
|
1538
1588
|
const preset = PRESETS[pid];
|
|
1539
1589
|
if (preset) setSounds({ ...preset.sounds });
|
|
@@ -1544,12 +1594,15 @@ const InstallApp = () => {
|
|
|
1544
1594
|
case SCREEN.SCOPE:
|
|
1545
1595
|
return h(ScopeScreen, {
|
|
1546
1596
|
tts,
|
|
1597
|
+
outdatedReasons,
|
|
1547
1598
|
onToggleTts: () => setTts((v) => !v),
|
|
1548
1599
|
onNext: (s) => {
|
|
1549
1600
|
setScope(s);
|
|
1601
|
+
// Refresh sounds/outdated for the selected scope
|
|
1550
1602
|
getExistingSounds(s).then((existing) => {
|
|
1551
1603
|
if (Object.keys(existing).length > 0) setSounds(existing);
|
|
1552
1604
|
});
|
|
1605
|
+
checkHooksOutdated(s).then(setOutdatedReasons).catch(() => {});
|
|
1553
1606
|
setScreen(SCREEN.PRESET);
|
|
1554
1607
|
},
|
|
1555
1608
|
onMusic: () => setScreen(SCREEN.MUSIC_MODE),
|
|
@@ -1558,6 +1611,7 @@ const InstallApp = () => {
|
|
|
1558
1611
|
case SCREEN.PRESET:
|
|
1559
1612
|
return h(PresetScreen, {
|
|
1560
1613
|
existingSounds: sounds,
|
|
1614
|
+
outdatedReasons,
|
|
1561
1615
|
onReapply: () => setScreen(SCREEN.CONFIRM),
|
|
1562
1616
|
onNext: (id) => {
|
|
1563
1617
|
if (id === "_music") {
|
|
@@ -1660,6 +1714,7 @@ const InstallApp = () => {
|
|
|
1660
1714
|
sounds,
|
|
1661
1715
|
tts,
|
|
1662
1716
|
voice,
|
|
1717
|
+
hasKokoro,
|
|
1663
1718
|
onToggleTts: () => setTts((v) => !v),
|
|
1664
1719
|
onCycleVoice: () => setVoice((v) => {
|
|
1665
1720
|
const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
|
package/src/installer.js
CHANGED
|
@@ -118,8 +118,8 @@ async function installApprovalHooks(settings, soundPath, claudeDir) {
|
|
|
118
118
|
// Write the timer script
|
|
119
119
|
const script = `#!/usr/bin/env bash
|
|
120
120
|
# klaudio: approval notification timer
|
|
121
|
-
# Plays a sound +
|
|
122
|
-
DELAY=
|
|
121
|
+
# Plays a sound + sends a system notification if a tool isn't approved within DELAY seconds.
|
|
122
|
+
DELAY=60
|
|
123
123
|
MARKER="/tmp/.claude-approval-pending"
|
|
124
124
|
SOUND="${normalized}"
|
|
125
125
|
|
|
@@ -135,6 +135,7 @@ case "$1" in
|
|
|
135
135
|
PROJECT=$(tail -1 "$MARKER" 2>/dev/null | sed 's|.*[/\\\\]||')
|
|
136
136
|
rm -f "$MARKER"
|
|
137
137
|
npx klaudio play "$SOUND" 2>/dev/null
|
|
138
|
+
npx klaudio notify "\${PROJECT:-project}" "Waiting for your approval" 2>/dev/null
|
|
138
139
|
npx klaudio say "\${PROJECT:-project} needs your attention" 2>/dev/null
|
|
139
140
|
fi
|
|
140
141
|
) &
|
|
@@ -268,6 +269,58 @@ export async function getExistingSounds(scope) {
|
|
|
268
269
|
return sounds;
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Check if existing hooks are outdated (missing features from newer versions).
|
|
274
|
+
* Returns a list of reasons why hooks should be updated.
|
|
275
|
+
*/
|
|
276
|
+
export async function checkHooksOutdated(scope) {
|
|
277
|
+
const claudeDir = getTargetDir(scope);
|
|
278
|
+
const settingsFile = join(claudeDir, "settings.json");
|
|
279
|
+
const reasons = [];
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const existing = await readFile(settingsFile, "utf-8");
|
|
283
|
+
const settings = JSON.parse(existing);
|
|
284
|
+
if (!settings.hooks) return reasons;
|
|
285
|
+
|
|
286
|
+
// Check Stop hook for --notify flag
|
|
287
|
+
const stopEntries = settings.hooks.Stop || [];
|
|
288
|
+
const stopHook = stopEntries.find((e) => e._klaudio || e.hooks?.[0]?.command?.includes("klaudio"));
|
|
289
|
+
if (stopHook?.hooks?.[0]?.command && !stopHook.hooks[0].command.includes("--notify")) {
|
|
290
|
+
reasons.push("System notifications on task complete");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Check Notification hook for --notify flag
|
|
294
|
+
const notifEntries = settings.hooks.Notification || [];
|
|
295
|
+
const notifHook = notifEntries.find((e) => e._klaudio || e.hooks?.[0]?.command?.includes("klaudio"));
|
|
296
|
+
if (notifHook?.hooks?.[0]?.command && !notifHook.hooks[0].command.includes("--notify")) {
|
|
297
|
+
reasons.push("System notifications on background task");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check approval script for notify command and timer delay
|
|
301
|
+
const scriptPath = join(claudeDir, "approval-notify.sh");
|
|
302
|
+
try {
|
|
303
|
+
const script = await readFile(scriptPath, "utf-8");
|
|
304
|
+
if (!script.includes("klaudio notify")) {
|
|
305
|
+
reasons.push("System notifications on approval wait");
|
|
306
|
+
}
|
|
307
|
+
if (script.includes("DELAY=15")) {
|
|
308
|
+
reasons.push("Approval timer too short (15s -> 60s)");
|
|
309
|
+
}
|
|
310
|
+
} catch { /* no script */ }
|
|
311
|
+
|
|
312
|
+
// Check for duplicate hooks (old bug)
|
|
313
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
314
|
+
const klaudioEntries = entries.filter((e) => e._klaudio || e._klonk);
|
|
315
|
+
if (klaudioEntries.length > 1) {
|
|
316
|
+
reasons.push(`Duplicate ${event} hooks`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch { /* no existing config */ }
|
|
320
|
+
|
|
321
|
+
return reasons;
|
|
322
|
+
}
|
|
323
|
+
|
|
271
324
|
/**
|
|
272
325
|
* Uninstall klaudio hooks from settings.
|
|
273
326
|
*/
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Send a native OS notification (fire-and-forget).
|
|
6
|
+
* Click-to-focus: activates the terminal or editor that triggered it.
|
|
7
|
+
*
|
|
8
|
+
* Windows: WinRT toast (Win10+), focuses Windows Terminal or VS Code on click
|
|
9
|
+
* macOS: terminal-notifier (if installed) or osascript fallback
|
|
10
|
+
* Linux: notify-send
|
|
11
|
+
*/
|
|
12
|
+
export function sendNotification(title, body) {
|
|
13
|
+
const os = platform();
|
|
14
|
+
try {
|
|
15
|
+
if (os === "win32") return notifyWindows(title, body);
|
|
16
|
+
if (os === "darwin") return notifyMac(title, body);
|
|
17
|
+
return notifyLinux(title, body);
|
|
18
|
+
} catch {
|
|
19
|
+
return Promise.resolve();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detect the terminal/editor environment.
|
|
25
|
+
*/
|
|
26
|
+
function detectTerminal() {
|
|
27
|
+
const tp = process.env.TERM_PROGRAM;
|
|
28
|
+
if (tp === "vscode") return "vscode";
|
|
29
|
+
if (tp === "cursor") return "cursor";
|
|
30
|
+
if (tp === "iTerm.app") return "iterm";
|
|
31
|
+
if (tp === "Apple_Terminal") return "terminal";
|
|
32
|
+
if (process.env.WT_SESSION) return "windows-terminal";
|
|
33
|
+
// Fallback: check PATH for clues (hooks inherit the terminal's env)
|
|
34
|
+
const path = process.env.PATH || "";
|
|
35
|
+
if (/cursor[/\\]/i.test(path) && /resources[/\\]app[/\\]bin/i.test(path)) return "cursor";
|
|
36
|
+
if (/VS Code[/\\]bin/i.test(path) || /Code[/\\]bin/i.test(path)) return "vscode";
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeXml(s) {
|
|
41
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Windows ──────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function notifyWindows(title, body) {
|
|
47
|
+
const safeTitle = escapeXml(title);
|
|
48
|
+
const safeBody = escapeXml(body);
|
|
49
|
+
|
|
50
|
+
// Determine activation strategy based on the terminal we're running in
|
|
51
|
+
let toastAttrs = "";
|
|
52
|
+
let appId;
|
|
53
|
+
const terminal = detectTerminal();
|
|
54
|
+
|
|
55
|
+
if (terminal === "vscode" || terminal === "cursor") {
|
|
56
|
+
// VS Code / Cursor: use protocol handler to focus the editor on click
|
|
57
|
+
const protocol = terminal === "cursor" ? "cursor://" : "vscode://";
|
|
58
|
+
toastAttrs = ` activationType="protocol" launch="${protocol}"`;
|
|
59
|
+
appId = "klaudio";
|
|
60
|
+
} else if (terminal === "windows-terminal") {
|
|
61
|
+
// Windows Terminal: use its AUMID so clicking focuses WT
|
|
62
|
+
appId = "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App";
|
|
63
|
+
} else {
|
|
64
|
+
appId = "klaudio";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const toastXml = `<toast${toastAttrs}><visual><binding template="ToastGeneric"><text>${safeTitle}</text><text>${safeBody}</text></binding></visual></toast>`;
|
|
68
|
+
|
|
69
|
+
// PowerShell script: show WinRT toast notification
|
|
70
|
+
const ps = `\
|
|
71
|
+
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
72
|
+
[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime]
|
|
73
|
+
$x = [Windows.Data.Xml.Dom.XmlDocument]::new()
|
|
74
|
+
$x.LoadXml('${toastXml.replace(/'/g, "''")}')
|
|
75
|
+
$t = [Windows.UI.Notifications.ToastNotification]::new($x)
|
|
76
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${appId}').Show($t)`;
|
|
77
|
+
|
|
78
|
+
// Run detached so the Node process can exit immediately
|
|
79
|
+
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], {
|
|
80
|
+
windowsHide: true,
|
|
81
|
+
detached: true,
|
|
82
|
+
stdio: "ignore",
|
|
83
|
+
});
|
|
84
|
+
child.unref();
|
|
85
|
+
return Promise.resolve();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── macOS ────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function notifyMac(title, body) {
|
|
91
|
+
// Determine which app to activate when the notification is clicked
|
|
92
|
+
const terminal = detectTerminal();
|
|
93
|
+
const bundleIds = {
|
|
94
|
+
vscode: "com.microsoft.VSCode",
|
|
95
|
+
cursor: "com.todesktop.230313mzl4w4u92",
|
|
96
|
+
iterm: "com.googlecode.iterm2",
|
|
97
|
+
terminal: "com.apple.Terminal",
|
|
98
|
+
};
|
|
99
|
+
const bundleId = bundleIds[terminal] || "com.apple.Terminal";
|
|
100
|
+
|
|
101
|
+
// Try terminal-notifier first (best UX: click-to-focus), fall back to osascript
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const child = spawn("terminal-notifier", [
|
|
104
|
+
"-title", title, "-message", body,
|
|
105
|
+
"-activate", bundleId, "-sender", bundleId,
|
|
106
|
+
"-sound", "default",
|
|
107
|
+
], { stdio: "ignore", timeout: 10000 });
|
|
108
|
+
|
|
109
|
+
child.on("error", () => {
|
|
110
|
+
// terminal-notifier not installed — fall back to osascript
|
|
111
|
+
const safeTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
112
|
+
const safeBody = body.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
113
|
+
const script = `display notification "${safeBody}" with title "${safeTitle}"`;
|
|
114
|
+
const child2 = spawn("osascript", ["-e", script], {
|
|
115
|
+
stdio: "ignore",
|
|
116
|
+
detached: true,
|
|
117
|
+
});
|
|
118
|
+
child2.unref();
|
|
119
|
+
resolve();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
child.on("close", () => resolve());
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Linux ────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function notifyLinux(title, body) {
|
|
129
|
+
const child = spawn("notify-send", ["-a", "klaudio", title, body], {
|
|
130
|
+
stdio: "ignore",
|
|
131
|
+
detached: true,
|
|
132
|
+
});
|
|
133
|
+
child.unref();
|
|
134
|
+
return Promise.resolve();
|
|
135
|
+
}
|
package/src/player.js
CHANGED
|
@@ -386,6 +386,30 @@ export async function handlePlayCommand(args) {
|
|
|
386
386
|
if (stdinData.trim()) hookData = JSON.parse(stdinData);
|
|
387
387
|
} catch { /* no stdin or invalid JSON */ }
|
|
388
388
|
|
|
389
|
+
const notify = args.includes("--notify");
|
|
390
|
+
|
|
391
|
+
// Send system notification (detached, never blocks)
|
|
392
|
+
if (notify) {
|
|
393
|
+
const project = hookData.cwd ? hookData.cwd.replace(/\\/g, "/").split("/").pop() : null;
|
|
394
|
+
const notifTitle = project || "klaudio";
|
|
395
|
+
let notifBody = "Task complete";
|
|
396
|
+
if (hookData.last_assistant_message) {
|
|
397
|
+
// Extract first sentence for the notification body
|
|
398
|
+
const plain = hookData.last_assistant_message
|
|
399
|
+
.replace(/```[\s\S]*?```/g, "").replace(/`([^`]+)`/g, "$1")
|
|
400
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1")
|
|
401
|
+
.replace(/#{1,6}\s+/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
402
|
+
.replace(/\([^)]*\)/g, "").replace(/\n+/g, " ").trim();
|
|
403
|
+
const first = plain.match(/^[^.!?]*[.!?]/)?.[0] || plain.slice(0, 120);
|
|
404
|
+
notifBody = first.trim();
|
|
405
|
+
} else if (hookData.message) {
|
|
406
|
+
notifBody = hookData.message.slice(0, 120);
|
|
407
|
+
}
|
|
408
|
+
import("./notify.js").then(({ sendNotification }) =>
|
|
409
|
+
sendNotification(notifTitle, notifBody)
|
|
410
|
+
).catch(() => {});
|
|
411
|
+
}
|
|
412
|
+
|
|
389
413
|
// Play sound (fire and forget, don't wait)
|
|
390
414
|
const soundPromise = soundFile
|
|
391
415
|
? playSoundWithCancel(soundFile).promise.catch(() => {})
|
|
@@ -455,9 +479,10 @@ export async function handlePlayCommand(args) {
|
|
|
455
479
|
/**
|
|
456
480
|
* Generate the shell command string for use in Claude Code hooks.
|
|
457
481
|
*/
|
|
458
|
-
export function getHookPlayCommand(soundFilePath, { tts = false, voice } = {}) {
|
|
482
|
+
export function getHookPlayCommand(soundFilePath, { tts = false, voice, notify = true } = {}) {
|
|
459
483
|
const normalized = soundFilePath.replace(/\\/g, "/");
|
|
460
484
|
const ttsFlag = tts ? " --tts" : "";
|
|
461
485
|
const voiceFlag = tts && voice ? ` --voice ${voice}` : "";
|
|
462
|
-
|
|
486
|
+
const notifyFlag = notify ? " --notify" : "";
|
|
487
|
+
return `npx klaudio play "${normalized}"${ttsFlag}${voiceFlag}${notifyFlag}`;
|
|
463
488
|
}
|
package/src/tts.js
CHANGED
|
@@ -66,6 +66,16 @@ async function ensureKokoroInstalled() {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Check if kokoro-js is available without loading the model.
|
|
71
|
+
*/
|
|
72
|
+
export async function isKokoroAvailable() {
|
|
73
|
+
try {
|
|
74
|
+
await importKokoro();
|
|
75
|
+
return true;
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
/**
|
|
70
80
|
* Try to import kokoro-js from various locations.
|
|
71
81
|
*/
|