cvox 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/README.md +73 -0
- package/dist/commands/init.js +14 -0
- package/dist/commands/notify.js +78 -0
- package/dist/hooks/config.js +23 -0
- package/dist/index.js +19 -0
- package/dist/utils/config.js +62 -0
- package/dist/utils/settings.js +46 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# cvox
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/cvox)
|
|
4
|
+
|
|
5
|
+
Voice notifications for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hooks. Get spoken alerts when Claude needs permission or finishes a task — so you can step away from the screen.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Cross-platform TTS: macOS (`say`), Linux (`espeak`), Windows (SAPI via PowerShell)
|
|
10
|
+
- Two hook events: permission prompt and task completion
|
|
11
|
+
- Three-layer config merging: defaults → `~/.cvox.json` → project `.cvox.json`
|
|
12
|
+
- Idempotent installation — safe to run multiple times
|
|
13
|
+
- `{project}` placeholder in messages, auto-detected from directory name
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# One-liner: install hooks into your project
|
|
19
|
+
npx cvox init
|
|
20
|
+
|
|
21
|
+
# Or install globally
|
|
22
|
+
npm install -g cvox
|
|
23
|
+
cvox init
|
|
24
|
+
|
|
25
|
+
# Install hooks globally (applies to all projects)
|
|
26
|
+
cvox init --global
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That's it. Claude Code will now speak to you when it needs attention.
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Create a `.cvox.json` in your project root or home directory (`~/.cvox.json`) to customize behavior:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"project": "my-app",
|
|
38
|
+
"hooks": {
|
|
39
|
+
"notification": {
|
|
40
|
+
"enabled": true,
|
|
41
|
+
"message": "{project} needs permission"
|
|
42
|
+
},
|
|
43
|
+
"stop": {
|
|
44
|
+
"enabled": true,
|
|
45
|
+
"message": "{project} task complete"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"tts": {
|
|
49
|
+
"enabled": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Field | Type | Default | Description |
|
|
55
|
+
|-------|------|---------|-------------|
|
|
56
|
+
| `project` | string | directory name | Project name used in `{project}` placeholder |
|
|
57
|
+
| `hooks.notification.enabled` | boolean | `true` | Enable voice alert on permission prompts |
|
|
58
|
+
| `hooks.notification.message` | string | `"{project} needs permission"` | Message spoken on permission prompt |
|
|
59
|
+
| `hooks.stop.enabled` | boolean | `true` | Enable voice alert on task completion |
|
|
60
|
+
| `hooks.stop.message` | string | `"{project} task completed"` | Message spoken on task completion |
|
|
61
|
+
| `tts.enabled` | boolean | `true` | Enable/disable TTS globally |
|
|
62
|
+
|
|
63
|
+
Config files are merged with deep merge — you only need to specify the fields you want to override.
|
|
64
|
+
|
|
65
|
+
## How It Works
|
|
66
|
+
|
|
67
|
+
1. `cvox init` injects hooks into Claude Code's `settings.json`
|
|
68
|
+
2. When Claude Code triggers a hook event (permission prompt or stop), it pipes a JSON payload via stdin to `cvox notify`
|
|
69
|
+
3. `cvox notify` reads the event, loads your config, and calls the platform TTS engine to speak the message
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { generateHooksConfig } from "../hooks/config.js";
|
|
2
|
+
import { getSettingsPath, readSettings, writeSettings, mergeHooks, } from "../utils/settings.js";
|
|
3
|
+
export function initCommand(options) {
|
|
4
|
+
const isGlobal = options.global ?? false;
|
|
5
|
+
const settingsPath = getSettingsPath(isGlobal);
|
|
6
|
+
const settings = readSettings(settingsPath);
|
|
7
|
+
const cvoxHooks = generateHooksConfig();
|
|
8
|
+
const merged = mergeHooks(settings, cvoxHooks);
|
|
9
|
+
writeSettings(settingsPath, merged);
|
|
10
|
+
const target = isGlobal ? "全局" : "项目";
|
|
11
|
+
console.log(`cvox: 已配置 ${target} hooks → ${settingsPath}`);
|
|
12
|
+
console.log(" - Notification (permission_prompt)");
|
|
13
|
+
console.log(" - Stop");
|
|
14
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { loadConfig } from "../utils/config.js";
|
|
3
|
+
function readStdin() {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
let data = "";
|
|
6
|
+
process.stdin.setEncoding("utf-8");
|
|
7
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
8
|
+
process.stdin.on("end", () => resolve(data));
|
|
9
|
+
if (process.stdin.isTTY) {
|
|
10
|
+
resolve("");
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function mapEventName(hookEventName) {
|
|
15
|
+
const map = {
|
|
16
|
+
Notification: "notification",
|
|
17
|
+
Stop: "stop",
|
|
18
|
+
};
|
|
19
|
+
return map[hookEventName] ?? null;
|
|
20
|
+
}
|
|
21
|
+
function speak(message, config) {
|
|
22
|
+
const { tts } = config;
|
|
23
|
+
if (!tts.enabled)
|
|
24
|
+
return;
|
|
25
|
+
const engine = detectEngine();
|
|
26
|
+
switch (engine) {
|
|
27
|
+
case "say": {
|
|
28
|
+
execFile("say", [message], () => { });
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case "espeak": {
|
|
32
|
+
execFile("espeak", [message], () => { });
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case "sapi": {
|
|
36
|
+
const ps = `Add-Type -AssemblyName System.Speech; ` +
|
|
37
|
+
`$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; ` +
|
|
38
|
+
`$s.Speak('${message.replace(/'/g, "''")}')`;
|
|
39
|
+
execFile("powershell", ["-Command", ps], () => { });
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function detectEngine() {
|
|
45
|
+
switch (process.platform) {
|
|
46
|
+
case "darwin":
|
|
47
|
+
return "say";
|
|
48
|
+
case "win32":
|
|
49
|
+
return "sapi";
|
|
50
|
+
default:
|
|
51
|
+
return "espeak";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function notifyCommand() {
|
|
55
|
+
const raw = await readStdin();
|
|
56
|
+
if (!raw.trim())
|
|
57
|
+
return;
|
|
58
|
+
let input;
|
|
59
|
+
try {
|
|
60
|
+
input = JSON.parse(raw);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const eventName = input.hook_event_name;
|
|
66
|
+
if (!eventName)
|
|
67
|
+
return;
|
|
68
|
+
const eventKey = mapEventName(eventName);
|
|
69
|
+
if (!eventKey)
|
|
70
|
+
return;
|
|
71
|
+
const cwd = input.cwd || process.cwd();
|
|
72
|
+
const config = loadConfig(cwd);
|
|
73
|
+
const hookConfig = config.hooks[eventKey];
|
|
74
|
+
if (!hookConfig.enabled)
|
|
75
|
+
return;
|
|
76
|
+
const message = hookConfig.message.replace(/\{project\}/g, config.project);
|
|
77
|
+
speak(message, config);
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function generateHooksConfig() {
|
|
2
|
+
const notifyHook = {
|
|
3
|
+
type: "command",
|
|
4
|
+
command: "cvox notify",
|
|
5
|
+
async: true,
|
|
6
|
+
};
|
|
7
|
+
return {
|
|
8
|
+
hooks: {
|
|
9
|
+
Notification: [
|
|
10
|
+
{
|
|
11
|
+
matcher: "permission_prompt",
|
|
12
|
+
hooks: [notifyHook],
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
Stop: [
|
|
16
|
+
{
|
|
17
|
+
matcher: "",
|
|
18
|
+
hooks: [{ ...notifyHook }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { initCommand } from "./commands/init.js";
|
|
4
|
+
import { notifyCommand } from "./commands/notify.js";
|
|
5
|
+
const program = new Command();
|
|
6
|
+
program
|
|
7
|
+
.name("cvox")
|
|
8
|
+
.description("Claude Voice Notifications")
|
|
9
|
+
.version("1.0.0");
|
|
10
|
+
program
|
|
11
|
+
.command("init")
|
|
12
|
+
.description("向 Claude Code settings 注入 hooks")
|
|
13
|
+
.option("--global", "写入全局 ~/.claude/settings.json")
|
|
14
|
+
.action(initCommand);
|
|
15
|
+
program
|
|
16
|
+
.command("notify")
|
|
17
|
+
.description("被 hooks 调用,读取 stdin 播放语音通知")
|
|
18
|
+
.action(notifyCommand);
|
|
19
|
+
program.parse();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
export const DEFAULT_CONFIG = {
|
|
5
|
+
project: "",
|
|
6
|
+
hooks: {
|
|
7
|
+
notification: {
|
|
8
|
+
enabled: true,
|
|
9
|
+
message: "{project} needs permission",
|
|
10
|
+
},
|
|
11
|
+
stop: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
message: "{project} task completed",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
tts: {
|
|
17
|
+
enabled: true,
|
|
18
|
+
},
|
|
19
|
+
desktop: {
|
|
20
|
+
enabled: false,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
function deepMerge(target, source) {
|
|
24
|
+
const result = { ...target };
|
|
25
|
+
for (const key of Object.keys(source)) {
|
|
26
|
+
if (source[key] &&
|
|
27
|
+
typeof source[key] === "object" &&
|
|
28
|
+
!Array.isArray(source[key]) &&
|
|
29
|
+
target[key] &&
|
|
30
|
+
typeof target[key] === "object") {
|
|
31
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
result[key] = source[key];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
function tryReadJson(filePath) {
|
|
40
|
+
try {
|
|
41
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
42
|
+
return JSON.parse(content);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function loadConfig(cwd) {
|
|
49
|
+
const projectConfig = tryReadJson(path.join(cwd, ".cvox.json"));
|
|
50
|
+
const globalConfig = tryReadJson(path.join(os.homedir(), ".cvox.json"));
|
|
51
|
+
let config = { ...DEFAULT_CONFIG };
|
|
52
|
+
if (globalConfig) {
|
|
53
|
+
config = deepMerge(config, globalConfig);
|
|
54
|
+
}
|
|
55
|
+
if (projectConfig) {
|
|
56
|
+
config = deepMerge(config, projectConfig);
|
|
57
|
+
}
|
|
58
|
+
if (!config.project) {
|
|
59
|
+
config.project = path.basename(cwd);
|
|
60
|
+
}
|
|
61
|
+
return config;
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
const CVOX_MARKER = "cvox notify";
|
|
5
|
+
export function getSettingsPath(global, cwd) {
|
|
6
|
+
if (global) {
|
|
7
|
+
return path.join(os.homedir(), ".claude", "settings.json");
|
|
8
|
+
}
|
|
9
|
+
return path.join(cwd || process.cwd(), ".claude", "settings.local.json");
|
|
10
|
+
}
|
|
11
|
+
export function readSettings(filePath) {
|
|
12
|
+
try {
|
|
13
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
14
|
+
return JSON.parse(content);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function writeSettings(filePath, settings) {
|
|
21
|
+
const dir = path.dirname(filePath);
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + "\n");
|
|
24
|
+
}
|
|
25
|
+
function isCvoxHook(hook) {
|
|
26
|
+
return (hook &&
|
|
27
|
+
typeof hook.command === "string" &&
|
|
28
|
+
hook.command.includes(CVOX_MARKER));
|
|
29
|
+
}
|
|
30
|
+
function isCvoxMatcher(matcher) {
|
|
31
|
+
return (matcher &&
|
|
32
|
+
Array.isArray(matcher.hooks) &&
|
|
33
|
+
matcher.hooks.some(isCvoxHook));
|
|
34
|
+
}
|
|
35
|
+
export function mergeHooks(settings, cvoxHooks) {
|
|
36
|
+
const result = { ...settings };
|
|
37
|
+
const existingHooks = result.hooks || {};
|
|
38
|
+
const merged = { ...existingHooks };
|
|
39
|
+
for (const [eventName, cvoxMatchers] of Object.entries(cvoxHooks.hooks)) {
|
|
40
|
+
const existing = merged[eventName] || [];
|
|
41
|
+
const filtered = existing.filter((m) => !isCvoxMatcher(m));
|
|
42
|
+
merged[eventName] = [...filtered, ...cvoxMatchers];
|
|
43
|
+
}
|
|
44
|
+
result.hooks = merged;
|
|
45
|
+
return result;
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cvox",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Claude Voice Notifications - voice alerts for Claude Code hooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cvox": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"hooks",
|
|
20
|
+
"tts",
|
|
21
|
+
"voice",
|
|
22
|
+
"notifications",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/lmk123/cvox.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/lmk123/cvox#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/lmk123/cvox/issues"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=16"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"commander": "^12.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.14.0",
|
|
41
|
+
"typescript": "^5.5.3"
|
|
42
|
+
}
|
|
43
|
+
}
|