mcp-voice-hooks 1.0.2 → 1.0.6

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.
@@ -3,8 +3,11 @@
3
3
  # Pre-Tool Hook - Checks for pending utterances before allowing tool execution
4
4
  # Forces Claude to use dequeue_utterances tool if there are pending utterances
5
5
 
6
+ # Get port from environment variable or use default
7
+ PORT="${MCP_VOICE_HOOKS_PORT:-5111}"
8
+
6
9
  # Check has-pending-utterances endpoint
7
- response=$(curl -s http://localhost:3000/api/has-pending-utterances 2>/dev/null)
10
+ response=$(curl -s http://localhost:${PORT}/api/has-pending-utterances 2>/dev/null)
8
11
 
9
12
  if [ $? -ne 0 ]; then
10
13
  # Server not available, allow tool execution
@@ -3,8 +3,11 @@
3
3
  # Stop Hook - Intelligently decides whether to wait for voice input
4
4
  # Checks if there have been any utterances since the last timeout
5
5
 
6
+ # Get port from environment variable or use default
7
+ PORT="${MCP_VOICE_HOOKS_PORT:-5111}"
8
+
6
9
  # Check should-wait endpoint
7
- response=$(curl -s http://localhost:3000/api/should-wait 2>/dev/null)
10
+ response=$(curl -s http://localhost:${PORT}/api/should-wait 2>/dev/null)
8
11
 
9
12
  if [ $? -ne 0 ]; then
10
13
  # Server not available, allow stop
package/README.md CHANGED
@@ -23,17 +23,7 @@ mcp-voice-hooks enables continuous voice conversations with AI assistants by:
23
23
 
24
24
  ## Installation in Your Own Project
25
25
 
26
- 1. **Install the hooks** (first time only):
27
-
28
- ```bash
29
- npx mcp-voice-hooks install-hooks
30
- ```
31
-
32
- This will:
33
- - Install hook scripts to `~/.mcp-voice-hooks/hooks/`
34
- - Configure your project's `.claude/settings.json`
35
-
36
- 2. **Add the MCP server**:
26
+ 1. **Add the MCP server**:
37
27
 
38
28
  Run the following command to automatically add the MCP server to your current project in `~/.claude.json`:
39
29
 
@@ -56,13 +46,17 @@ mcp-voice-hooks enables continuous voice conversations with AI assistants by:
56
46
  }
57
47
  ```
58
48
 
59
- 3. **Start Claude Code**:
49
+ 2. **Start Claude Code**:
60
50
 
61
51
  ```bash
62
52
  claude
63
53
  ```
64
54
 
65
- 4. **Open the voice interface** at <http://localhost:5111> and start speaking! Note: you need to send one text message to Claude to trigger the voice hooks.
55
+ 3. **Open the voice interface** at <http://localhost:5111> and start speaking!
56
+
57
+ The hooks are automatically installed when the MCP server starts. You need to send one text message to Claude to trigger the voice hooks.
58
+
59
+ **Note**: After the first-time installation, you may need to restart Claude for the hooks to take effect.
66
60
 
67
61
  The default port is 5111. To use a different port, add to your project's `.claude/settings.json`:
68
62
 
@@ -74,13 +68,45 @@ mcp-voice-hooks enables continuous voice conversations with AI assistants by:
74
68
  }
75
69
  ```
76
70
 
71
+ ## Manual Hook Installation
72
+
73
+ The hooks are automatically installed/updated when the MCP server starts. However, if you need to manually install or reconfigure the hooks:
74
+
75
+ ```bash
76
+ npx mcp-voice-hooks install-hooks
77
+ ```
78
+
79
+ This will:
80
+
81
+ - Clean up any existing `~/.mcp-voice-hooks` directory contents
82
+ - Install/update hook scripts to `~/.mcp-voice-hooks/hooks/`
83
+ - Configure your project's `.claude/settings.json`
84
+
85
+ ## Uninstallation
86
+
87
+ To completely remove MCP Voice Hooks:
88
+
89
+ ```bash
90
+ # Remove hooks and settings
91
+ npx mcp-voice-hooks uninstall
92
+
93
+ # Also remove from Claude MCP servers
94
+ claude mcp remove voice-hooks
95
+ ```
96
+
97
+ This will:
98
+
99
+ - Remove the `~/.mcp-voice-hooks` directory
100
+ - Clean up voice hooks from your project's `.claude/settings.json`
101
+ - Preserve any custom hooks you've added
102
+
77
103
  ## Development Mode
78
104
 
79
105
  If you're developing mcp-voice-hooks itself:
80
106
 
81
107
  ```bash
82
108
  # 1. Clone the repository
83
- git clone https://github.com/yourusername/mcp-voice-hooks.git
109
+ git clone https://github.com/johnmatthewtennant/mcp-voice-hooks.git
84
110
  cd mcp-voice-hooks
85
111
 
86
112
  # 2. Install dependencies
@@ -99,8 +125,9 @@ claude
99
125
  **Important**: When developing with `npm link`:
100
126
 
101
127
  - Claude runs the compiled JavaScript from the `dist` folder, not your TypeScript source
102
- - After making changes to the TypeScript code, you must run `npm run build`
103
- - Then restart Claude Code to use the updated compiled code
128
+ - After making changes to **TypeScript files** (`src/*.ts`), you must run `npm run build`
129
+ - For changes to **browser files** (`public/*`), just restart Claude Code
130
+ - Then restart Claude Code to use the updated code
104
131
 
105
132
  ### Hot Reload
106
133
 
package/bin/cli.js CHANGED
@@ -5,6 +5,7 @@ import path from 'path';
5
5
  import os from 'os';
6
6
  import { spawn } from 'child_process';
7
7
  import { fileURLToPath } from 'url';
8
+ import { replaceVoiceHooks, areHooksEqual } from '../dist/hook-merger.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = path.dirname(__filename);
@@ -26,10 +27,16 @@ async function main() {
26
27
 
27
28
  console.log('\n✅ Installation complete!');
28
29
  console.log('📝 To start the server, run: npx mcp-voice-hooks');
30
+ } else if (command === 'uninstall') {
31
+ console.log('🗑️ Uninstalling MCP Voice Hooks...');
32
+ await uninstall();
29
33
  } else {
30
- // Default behavior: just run the MCP server
34
+ // Default behavior: ensure hooks are installed/updated, then run the MCP server
31
35
  console.log('🎤 MCP Voice Hooks - Starting server...');
32
- console.log('💡 Note: If hooks are not installed, run: npx mcp-voice-hooks install-hooks');
36
+
37
+ // Auto-install/update hooks on every startup
38
+ await ensureHooksInstalled();
39
+
33
40
  console.log('');
34
41
  await runMCPServer();
35
42
  }
@@ -46,7 +53,30 @@ async function ensureUserDirectorySetup() {
46
53
 
47
54
  console.log('📁 Setting up user directory:', userDir);
48
55
 
49
- // Create directories if they don't exist
56
+ // Clean up existing directory contents (except README.md if it exists)
57
+ if (fs.existsSync(userDir)) {
58
+ console.log('🧹 Cleaning up existing directory...');
59
+
60
+ // Remove hooks directory if it exists
61
+ if (fs.existsSync(hooksDir)) {
62
+ fs.rmSync(hooksDir, { recursive: true, force: true });
63
+ console.log('✅ Cleaned up old hooks');
64
+ }
65
+
66
+ // Remove any other files
67
+ const files = fs.readdirSync(userDir);
68
+ for (const file of files) {
69
+ const filePath = path.join(userDir, file);
70
+ const stat = fs.statSync(filePath);
71
+ if (stat.isDirectory()) {
72
+ fs.rmSync(filePath, { recursive: true, force: true });
73
+ } else {
74
+ fs.unlinkSync(filePath);
75
+ }
76
+ }
77
+ }
78
+
79
+ // Create directories
50
80
  if (!fs.existsSync(userDir)) {
51
81
  fs.mkdirSync(userDir, { recursive: true });
52
82
  console.log('✅ Created user directory');
@@ -74,6 +104,15 @@ async function ensureUserDirectorySetup() {
74
104
  } else {
75
105
  console.log('⚠️ Package hooks directory not found, skipping hook installation');
76
106
  }
107
+
108
+ // Copy README.md from project root to user directory
109
+ const projectReadmePath = path.join(__dirname, '..', 'README.md');
110
+ const userReadmePath = path.join(userDir, 'README.md');
111
+
112
+ if (fs.existsSync(projectReadmePath)) {
113
+ fs.copyFileSync(projectReadmePath, userReadmePath);
114
+ console.log('✅ Copied README.md');
115
+ }
77
116
  }
78
117
 
79
118
  // Automatically configure Claude Code settings
@@ -139,14 +178,76 @@ async function configureClaudeCodeSettings() {
139
178
  ]
140
179
  };
141
180
 
142
- // Update settings
143
- settings.hooks = hookConfig;
181
+ // Replace voice hooks intelligently
182
+ const updatedHooks = replaceVoiceHooks(settings.hooks || {}, hookConfig);
183
+
184
+ // Check if hooks actually changed (ignoring order)
185
+ if (areHooksEqual(settings.hooks || {}, updatedHooks)) {
186
+ console.log('✅ Claude settings already up to date');
187
+ return;
188
+ }
189
+
190
+ // Update settings with new hooks
191
+ settings.hooks = updatedHooks;
144
192
 
145
193
  // Write settings back
146
194
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
147
195
  console.log('✅ Updated project Claude Code settings');
148
196
  }
149
197
 
198
+ // Silent hook installation check - runs on every startup
199
+ async function ensureHooksInstalled() {
200
+ const userDir = path.join(os.homedir(), '.mcp-voice-hooks');
201
+ const hooksDir = path.join(userDir, 'hooks');
202
+ const packageHooksDir = path.join(__dirname, '..', '.claude', 'hooks');
203
+
204
+ try {
205
+ // Only create directories if they don't exist
206
+ if (!fs.existsSync(hooksDir)) {
207
+ fs.mkdirSync(hooksDir, { recursive: true });
208
+ console.log('🔧 Installing hooks for first time...');
209
+ }
210
+
211
+ // Check if hooks need updating
212
+ let needsUpdate = false;
213
+ if (fs.existsSync(packageHooksDir)) {
214
+ const hookFiles = fs.readdirSync(packageHooksDir).filter(file => file.endsWith('.sh'));
215
+
216
+ for (const hookFile of hookFiles) {
217
+ const sourcePath = path.join(packageHooksDir, hookFile);
218
+ const destPath = path.join(hooksDir, hookFile);
219
+
220
+ // Check if hook doesn't exist or is different
221
+ if (!fs.existsSync(destPath)) {
222
+ needsUpdate = true;
223
+ break;
224
+ }
225
+
226
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
227
+ const destContent = fs.readFileSync(destPath, 'utf8');
228
+
229
+ if (sourceContent !== destContent) {
230
+ needsUpdate = true;
231
+ break;
232
+ }
233
+ }
234
+ }
235
+
236
+ if (needsUpdate) {
237
+ console.log('🔄 Updating hooks to latest version...');
238
+ await ensureUserDirectorySetup();
239
+ await configureClaudeCodeSettings();
240
+ console.log('✅ Hooks and settings updated');
241
+ } else {
242
+ // Even if hooks are up to date, ensure settings are correct
243
+ await configureClaudeCodeSettings();
244
+ }
245
+ } catch (error) {
246
+ // Silently continue if hooks can't be updated
247
+ console.warn('⚠️ Could not auto-update hooks:', error.message);
248
+ }
249
+ }
250
+
150
251
  // Run the MCP server
151
252
  async function runMCPServer() {
152
253
  const serverPath = path.join(__dirname, '..', 'dist', 'unified-server.js');
@@ -179,6 +280,56 @@ async function runMCPServer() {
179
280
  });
180
281
  }
181
282
 
283
+ // Uninstall MCP Voice Hooks
284
+ async function uninstall() {
285
+ const userDir = path.join(os.homedir(), '.mcp-voice-hooks');
286
+ const claudeSettingsPath = path.join(process.cwd(), '.claude', 'settings.json');
287
+
288
+ // Step 1: Remove ~/.mcp-voice-hooks directory
289
+ if (fs.existsSync(userDir)) {
290
+ console.log('📁 Removing user directory:', userDir);
291
+ fs.rmSync(userDir, { recursive: true, force: true });
292
+ console.log('✅ Removed ~/.mcp-voice-hooks');
293
+ } else {
294
+ console.log('ℹ️ ~/.mcp-voice-hooks directory not found');
295
+ }
296
+
297
+ // Step 2: Remove voice hooks from Claude settings
298
+ if (fs.existsSync(claudeSettingsPath)) {
299
+ try {
300
+ console.log('⚙️ Removing voice hooks from Claude settings...');
301
+
302
+ const settingsContent = fs.readFileSync(claudeSettingsPath, 'utf8');
303
+ const settings = JSON.parse(settingsContent);
304
+
305
+ if (settings.hooks) {
306
+ // Remove voice hooks
307
+ const cleanedHooks = removeVoiceHooks(settings.hooks);
308
+
309
+ if (Object.keys(cleanedHooks).length === 0) {
310
+ // If no hooks remain, remove the hooks property entirely
311
+ delete settings.hooks;
312
+ } else {
313
+ settings.hooks = cleanedHooks;
314
+ }
315
+
316
+ // Write updated settings
317
+ fs.writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2));
318
+ console.log('✅ Removed voice hooks from Claude settings');
319
+ } else {
320
+ console.log('ℹ️ No hooks found in Claude settings');
321
+ }
322
+ } catch (error) {
323
+ console.log('⚠️ Could not update Claude settings:', error.message);
324
+ }
325
+ } else {
326
+ console.log('ℹ️ No Claude settings file found in current project');
327
+ }
328
+
329
+ console.log('\n✅ Uninstallation complete!');
330
+ console.log('👋 MCP Voice Hooks has been removed.');
331
+ }
332
+
182
333
  // Run the main function
183
334
  main().catch(error => {
184
335
  console.error('❌ Unexpected error:', error);
@@ -0,0 +1,33 @@
1
+ interface Hook {
2
+ type: string;
3
+ command: string;
4
+ }
5
+ interface HookConfig {
6
+ matcher: string;
7
+ hooks: Hook[];
8
+ }
9
+ interface HookSettings {
10
+ [hookType: string]: HookConfig[];
11
+ }
12
+ /**
13
+ * Removes any existing voice hooks that reference our directory
14
+ * @param hooks - The current hooks configuration
15
+ * @returns The hooks with voice hooks removed
16
+ */
17
+ declare function removeVoiceHooks(hooks?: HookSettings): HookSettings;
18
+ /**
19
+ * Replaces voice hooks - removes any existing ones and adds new ones
20
+ * @param existingHooks - The current hooks configuration
21
+ * @param voiceHooks - The voice hooks to add
22
+ * @returns The updated hooks configuration
23
+ */
24
+ declare function replaceVoiceHooks(existingHooks: HookSettings | undefined, voiceHooks: HookSettings): HookSettings;
25
+ /**
26
+ * Checks if two hook settings are semantically equal (ignoring order)
27
+ * @param hooks1 - First hooks configuration
28
+ * @param hooks2 - Second hooks configuration
29
+ * @returns True if they contain the same hooks regardless of order
30
+ */
31
+ declare function areHooksEqual(hooks1?: HookSettings, hooks2?: HookSettings): boolean;
32
+
33
+ export { type HookSettings, areHooksEqual, removeVoiceHooks, replaceVoiceHooks };
@@ -0,0 +1,54 @@
1
+ // src/hook-merger.ts
2
+ function removeVoiceHooks(hooks = {}) {
3
+ const cleaned = {};
4
+ const voiceHookPattern = /\.mcp-voice-hooks/;
5
+ for (const [hookType, hookArray] of Object.entries(hooks)) {
6
+ cleaned[hookType] = hookArray.filter((hookConfig) => {
7
+ return !hookConfig.hooks.some((hook) => voiceHookPattern.test(hook.command));
8
+ });
9
+ if (cleaned[hookType].length === 0) {
10
+ delete cleaned[hookType];
11
+ }
12
+ }
13
+ return cleaned;
14
+ }
15
+ function replaceVoiceHooks(existingHooks = {}, voiceHooks) {
16
+ const cleaned = removeVoiceHooks(existingHooks);
17
+ const result = JSON.parse(JSON.stringify(cleaned));
18
+ for (const [hookType, hookArray] of Object.entries(voiceHooks)) {
19
+ if (!result[hookType]) {
20
+ result[hookType] = hookArray;
21
+ } else {
22
+ result[hookType].push(...hookArray);
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+ function areHooksEqual(hooks1 = {}, hooks2 = {}) {
28
+ const types1 = Object.keys(hooks1).sort();
29
+ const types2 = Object.keys(hooks2).sort();
30
+ if (types1.join(",") !== types2.join(",")) {
31
+ return false;
32
+ }
33
+ for (const hookType of types1) {
34
+ const configs1 = hooks1[hookType];
35
+ const configs2 = hooks2[hookType];
36
+ if (configs1.length !== configs2.length) {
37
+ return false;
38
+ }
39
+ const normalized1 = configs1.map((config) => JSON.stringify(config)).sort();
40
+ const normalized2 = configs2.map((config) => JSON.stringify(config)).sort();
41
+ for (let i = 0; i < normalized1.length; i++) {
42
+ if (normalized1[i] !== normalized2[i]) {
43
+ return false;
44
+ }
45
+ }
46
+ }
47
+ return true;
48
+ }
49
+ export {
50
+ areHooksEqual,
51
+ removeVoiceHooks,
52
+ replaceVoiceHooks
53
+ };
54
+ //# sourceMappingURL=hook-merger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hook-merger.ts"],"sourcesContent":["interface Hook {\n type: string;\n command: string;\n}\n\ninterface HookConfig {\n matcher: string;\n hooks: Hook[];\n}\n\nexport interface HookSettings {\n [hookType: string]: HookConfig[];\n}\n\n/**\n * Removes any existing voice hooks that reference our directory\n * @param hooks - The current hooks configuration\n * @returns The hooks with voice hooks removed\n */\nexport function removeVoiceHooks(hooks: HookSettings = {}): HookSettings {\n const cleaned: HookSettings = {};\n const voiceHookPattern = /\\.mcp-voice-hooks/;\n \n for (const [hookType, hookArray] of Object.entries(hooks)) {\n cleaned[hookType] = hookArray.filter(hookConfig => {\n // Keep this hook config only if none of its commands reference our directory\n return !hookConfig.hooks.some(hook => voiceHookPattern.test(hook.command));\n });\n \n // Remove empty arrays\n if (cleaned[hookType].length === 0) {\n delete cleaned[hookType];\n }\n }\n \n return cleaned;\n}\n\n/**\n * Replaces voice hooks - removes any existing ones and adds new ones\n * @param existingHooks - The current hooks configuration\n * @param voiceHooks - The voice hooks to add\n * @returns The updated hooks configuration\n */\nexport function replaceVoiceHooks(existingHooks: HookSettings = {}, voiceHooks: HookSettings): HookSettings {\n // First, remove any existing voice hooks\n const cleaned = removeVoiceHooks(existingHooks);\n \n // Then merge in the new voice hooks\n const result: HookSettings = JSON.parse(JSON.stringify(cleaned)); // Deep clone\n \n for (const [hookType, hookArray] of Object.entries(voiceHooks)) {\n if (!result[hookType]) {\n result[hookType] = hookArray;\n } else {\n result[hookType].push(...hookArray);\n }\n }\n \n return result;\n}\n\n/**\n * Checks if two hook settings are semantically equal (ignoring order)\n * @param hooks1 - First hooks configuration\n * @param hooks2 - Second hooks configuration\n * @returns True if they contain the same hooks regardless of order\n */\nexport function areHooksEqual(hooks1: HookSettings = {}, hooks2: HookSettings = {}): boolean {\n const types1 = Object.keys(hooks1).sort();\n const types2 = Object.keys(hooks2).sort();\n \n // Different hook types\n if (types1.join(',') !== types2.join(',')) {\n return false;\n }\n \n // Check each hook type\n for (const hookType of types1) {\n const configs1 = hooks1[hookType];\n const configs2 = hooks2[hookType];\n \n if (configs1.length !== configs2.length) {\n return false;\n }\n \n // Create normalized strings for comparison\n const normalized1 = configs1\n .map(config => JSON.stringify(config))\n .sort();\n const normalized2 = configs2\n .map(config => JSON.stringify(config))\n .sort();\n \n // Compare sorted arrays\n for (let i = 0; i < normalized1.length; i++) {\n if (normalized1[i] !== normalized2[i]) {\n return false;\n }\n }\n }\n \n return true;\n}"],"mappings":";AAmBO,SAAS,iBAAiB,QAAsB,CAAC,GAAiB;AACvE,QAAM,UAAwB,CAAC;AAC/B,QAAM,mBAAmB;AAEzB,aAAW,CAAC,UAAU,SAAS,KAAK,OAAO,QAAQ,KAAK,GAAG;AACzD,YAAQ,QAAQ,IAAI,UAAU,OAAO,gBAAc;AAEjD,aAAO,CAAC,WAAW,MAAM,KAAK,UAAQ,iBAAiB,KAAK,KAAK,OAAO,CAAC;AAAA,IAC3E,CAAC;AAGD,QAAI,QAAQ,QAAQ,EAAE,WAAW,GAAG;AAClC,aAAO,QAAQ,QAAQ;AAAA,IACzB;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,kBAAkB,gBAA8B,CAAC,GAAG,YAAwC;AAE1G,QAAM,UAAU,iBAAiB,aAAa;AAG9C,QAAM,SAAuB,KAAK,MAAM,KAAK,UAAU,OAAO,CAAC;AAE/D,aAAW,CAAC,UAAU,SAAS,KAAK,OAAO,QAAQ,UAAU,GAAG;AAC9D,QAAI,CAAC,OAAO,QAAQ,GAAG;AACrB,aAAO,QAAQ,IAAI;AAAA,IACrB,OAAO;AACL,aAAO,QAAQ,EAAE,KAAK,GAAG,SAAS;AAAA,IACpC;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,cAAc,SAAuB,CAAC,GAAG,SAAuB,CAAC,GAAY;AAC3F,QAAM,SAAS,OAAO,KAAK,MAAM,EAAE,KAAK;AACxC,QAAM,SAAS,OAAO,KAAK,MAAM,EAAE,KAAK;AAGxC,MAAI,OAAO,KAAK,GAAG,MAAM,OAAO,KAAK,GAAG,GAAG;AACzC,WAAO;AAAA,EACT;AAGA,aAAW,YAAY,QAAQ;AAC7B,UAAM,WAAW,OAAO,QAAQ;AAChC,UAAM,WAAW,OAAO,QAAQ;AAEhC,QAAI,SAAS,WAAW,SAAS,QAAQ;AACvC,aAAO;AAAA,IACT;AAGA,UAAM,cAAc,SACjB,IAAI,YAAU,KAAK,UAAU,MAAM,CAAC,EACpC,KAAK;AACR,UAAM,cAAc,SACjB,IAAI,YAAU,KAAK,UAAU,MAAM,CAAC,EACpC,KAAK;AAGR,aAAS,IAAI,GAAG,IAAI,YAAY,QAAQ,KAAK;AAC3C,UAAI,YAAY,CAAC,MAAM,YAAY,CAAC,GAAG;AACrC,eAAO;AAAA,MACT;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-voice-hooks",
3
- "version": "1.0.2",
3
+ "version": "1.0.6",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -276,9 +276,6 @@ class VoiceHooksClient {
276
276
  }
277
277
 
278
278
  async clearAllUtterances() {
279
- if (!confirm('Are you sure you want to clear all utterances?')) {
280
- return;
281
- }
282
279
 
283
280
  this.clearAllBtn.disabled = true;
284
281
  this.clearAllBtn.textContent = 'Clearing...';