agentvibes 5.9.0 → 5.11.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.
Files changed (70) hide show
  1. package/.agentvibes/config.json +17 -12
  2. package/.agentvibes/install-manifest.json +98 -86
  3. package/.claude/audio/ui/CREDITS.txt +16 -0
  4. package/.claude/audio/ui/bling-success.wav +0 -0
  5. package/.claude/commands/agent-vibes/receiver.md +64 -0
  6. package/.claude/config/audio-effects.cfg +3 -3
  7. package/.claude/config/personality.txt +1 -0
  8. package/.claude/config/reverb-level.txt +1 -1
  9. package/.claude/github-star-reminder.txt +1 -1
  10. package/.claude/hooks/audio-processor.sh +57 -18
  11. package/.claude/hooks/kokoro-installer.sh +117 -0
  12. package/.claude/hooks/kokoro-server.py +219 -0
  13. package/.claude/hooks/kokoro-tts.py +141 -0
  14. package/.claude/hooks/piper-download-voices.sh +39 -16
  15. package/.claude/hooks/piper-installer.sh +11 -6
  16. package/.claude/hooks/piper-voice-manager.sh +6 -6
  17. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
  18. package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
  19. package/.claude/hooks/play-tts-kokoro.sh +127 -0
  20. package/.claude/hooks/play-tts-piper.sh +20 -13
  21. package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
  22. package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
  23. package/.claude/hooks/play-tts.sh +78 -2
  24. package/.claude/hooks/provider-commands.sh +71 -22
  25. package/.claude/hooks/provider-manager.sh +15 -7
  26. package/.claude/hooks/voice-manager.sh +6 -0
  27. package/.claude/hooks-windows/kokoro-server.py +219 -0
  28. package/.claude/hooks-windows/kokoro-tts.py +107 -0
  29. package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
  30. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +22 -16
  31. package/.claude/hooks-windows/play-tts.ps1 +29 -1
  32. package/README.md +12 -86
  33. package/RELEASE_NOTES.md +60 -0
  34. package/mcp-server/server.py +17 -7
  35. package/package.json +4 -3
  36. package/src/commands/install-mcp.js +730 -476
  37. package/src/console/app.js +28 -12
  38. package/src/console/audio-env.js +85 -1
  39. package/src/console/navigation.js +4 -0
  40. package/src/console/tabs/agents-tab.js +18 -12
  41. package/src/console/tabs/music-tab.js +7 -25
  42. package/src/console/tabs/receiver-tab.js +13 -13
  43. package/src/console/tabs/settings-tab.js +2 -2
  44. package/src/console/tabs/setup-tab.js +1708 -124
  45. package/src/console/tabs/voices-tab.js +76 -92
  46. package/src/console/widgets/format-utils.js +14 -2
  47. package/src/console/widgets/help-bar.js +55 -0
  48. package/src/console/widgets/personality-picker.js +2 -2
  49. package/src/console/widgets/reverb-picker.js +429 -41
  50. package/src/console/widgets/track-picker.js +60 -51
  51. package/src/i18n/de.js +204 -203
  52. package/src/i18n/en.js +1 -0
  53. package/src/i18n/es.js +204 -203
  54. package/src/i18n/fr.js +204 -203
  55. package/src/i18n/hi.js +204 -203
  56. package/src/i18n/ja.js +204 -203
  57. package/src/i18n/ko.js +204 -203
  58. package/src/i18n/pt.js +204 -203
  59. package/src/i18n/strings.js +54 -54
  60. package/src/i18n/zh-CN.js +204 -203
  61. package/src/installer.js +127 -34
  62. package/src/services/provider-service.js +178 -143
  63. package/src/services/provider-voice-catalog.js +126 -0
  64. package/src/services/tts-engine-service.js +53 -4
  65. package/src/utils/audio-duration-validator.js +341 -298
  66. package/src/utils/list-formatter.js +200 -194
  67. package/src/utils/platform-resolver.js +369 -0
  68. package/src/utils/preview-list-prompt.js +8 -0
  69. package/src/utils/provider-validator.js +79 -9
  70. package/templates/agentvibes-receiver.sh +6 -2
@@ -0,0 +1,369 @@
1
+ /**
2
+ * AgentVibes Cross-Platform Resolver
3
+ *
4
+ * Implements the Agent Vibes Cross-Platform Contract v1.0.
5
+ * Single source of truth for binary resolution, path conventions, and env var interface.
6
+ *
7
+ * Resolution order (authoritative):
8
+ * 1. ENV_OVERRIDE — AGENTVIBES_*_PATH env var; if invalid, fail HARD (no fallthrough)
9
+ * 2. which/where — first result that passes validation
10
+ * 3. HINT_PATHS — platform hint list in order; first valid wins
11
+ * 4. FAIL — structured error, exit code 2
12
+ *
13
+ * Supported platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64, win32-x64
14
+ * WSL2 is treated as linux-x64.
15
+ */
16
+
17
+ import { execFileSync } from 'child_process';
18
+ import os from 'os';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+
22
+ // ─── Platform Detection ───────────────────────────────────────────────────────
23
+
24
+ function isWSL() {
25
+ try {
26
+ return /microsoft|wsl/i.test(fs.readFileSync('/proc/version', 'utf8'));
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Detect the current platform ID.
34
+ * Set AGENTVIBES_PLATFORM to override (CI / testing only).
35
+ * @returns {string} One of: darwin-arm64, darwin-x64, linux-x64, linux-arm64, win32-x64, unknown
36
+ */
37
+ export function detectPlatform() {
38
+ const forced = process.env.AGENTVIBES_PLATFORM;
39
+ if (forced) return forced;
40
+
41
+ const p = process.platform;
42
+ const arch = process.arch;
43
+
44
+ if (p === 'linux' && isWSL()) return arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
45
+ if (p === 'darwin') return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
46
+ if (p === 'linux') return arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
47
+ if (p === 'win32') return 'win32-x64';
48
+ return 'unknown';
49
+ }
50
+
51
+ // ─── Path Hint Lists (only hardcoded paths in the entire codebase) ────────────
52
+
53
+ const PIPER_HINTS = {
54
+ 'darwin-arm64': () => [
55
+ path.join(os.homedir(), '.agentvibes', 'bin', 'piper'),
56
+ path.join(os.homedir(), '.local', 'bin', 'piper'),
57
+ path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin', 'piper'),
58
+ '/opt/homebrew/bin/piper',
59
+ '/usr/local/bin/piper',
60
+ ],
61
+ 'darwin-x64': () => [
62
+ path.join(os.homedir(), '.agentvibes', 'bin', 'piper'),
63
+ path.join(os.homedir(), '.local', 'bin', 'piper'),
64
+ path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin', 'piper'),
65
+ '/usr/local/bin/piper',
66
+ '/opt/homebrew/bin/piper',
67
+ ],
68
+ 'linux-x64': () => [
69
+ path.join(os.homedir(), '.agentvibes', 'bin', 'piper'),
70
+ path.join(os.homedir(), '.local', 'bin', 'piper'),
71
+ path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin', 'piper'),
72
+ '/usr/bin/piper',
73
+ '/usr/local/bin/piper',
74
+ '/snap/bin/piper',
75
+ ],
76
+ 'linux-arm64': () => [
77
+ path.join(os.homedir(), '.agentvibes', 'bin', 'piper'),
78
+ path.join(os.homedir(), '.local', 'bin', 'piper'),
79
+ path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin', 'piper'),
80
+ '/usr/bin/piper',
81
+ '/usr/local/bin/piper',
82
+ ],
83
+ 'win32-x64': () => {
84
+ const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
85
+ const localappdata = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
86
+ const programfiles = process.env.PROGRAMFILES || path.join('C:', 'Program Files');
87
+ return [
88
+ path.join(appdata, 'AgentVibes', 'bin', 'piper.exe'),
89
+ path.join(localappdata, 'AgentVibes', 'bin', 'piper.exe'),
90
+ path.join(programfiles, 'AgentVibes', 'bin', 'piper.exe'),
91
+ ];
92
+ },
93
+ };
94
+
95
+ const FFMPEG_HINTS = {
96
+ 'darwin-arm64': () => [
97
+ path.join(os.homedir(), '.agentvibes', 'bin', 'ffmpeg'),
98
+ '/opt/homebrew/bin/ffmpeg',
99
+ '/usr/local/bin/ffmpeg',
100
+ ],
101
+ 'darwin-x64': () => [
102
+ path.join(os.homedir(), '.agentvibes', 'bin', 'ffmpeg'),
103
+ '/usr/local/bin/ffmpeg',
104
+ '/opt/homebrew/bin/ffmpeg',
105
+ ],
106
+ 'linux-x64': () => [
107
+ path.join(os.homedir(), '.agentvibes', 'bin', 'ffmpeg'),
108
+ '/usr/bin/ffmpeg',
109
+ '/usr/local/bin/ffmpeg',
110
+ ],
111
+ 'linux-arm64': () => [
112
+ path.join(os.homedir(), '.agentvibes', 'bin', 'ffmpeg'),
113
+ '/usr/bin/ffmpeg',
114
+ '/usr/local/bin/ffmpeg',
115
+ ],
116
+ 'win32-x64': () => {
117
+ const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
118
+ const localappdata = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
119
+ const programfiles = process.env.PROGRAMFILES || path.join('C:', 'Program Files');
120
+ return [
121
+ path.join(appdata, 'AgentVibes', 'bin', 'ffmpeg.exe'),
122
+ path.join(localappdata, 'AgentVibes', 'bin', 'ffmpeg.exe'),
123
+ path.join(programfiles, 'ffmpeg', 'bin', 'ffmpeg.exe'),
124
+ ];
125
+ },
126
+ };
127
+
128
+ // Derive ffprobe hints by substituting the binary name in every ffmpeg path.
129
+ // ffprobe is always co-located with ffmpeg so sharing the directory list is correct.
130
+ function deriveBinaryHints(templateHints, binaryName) {
131
+ const result = {};
132
+ for (const [plat, fn] of Object.entries(templateHints)) {
133
+ result[plat] = () => fn().map(p => {
134
+ const dir = path.dirname(p);
135
+ const ext = path.extname(p);
136
+ return path.join(dir, binaryName + ext);
137
+ });
138
+ }
139
+ return result;
140
+ }
141
+
142
+ const FFPROBE_HINTS = deriveBinaryHints(FFMPEG_HINTS, 'ffprobe');
143
+
144
+ const BINARY_HINTS = { piper: PIPER_HINTS, ffmpeg: FFMPEG_HINTS, ffprobe: FFPROBE_HINTS };
145
+
146
+ /** Canonical env var names — only override surface permitted by the contract */
147
+ export const ENV_VARS = {
148
+ piper: 'AGENTVIBES_PIPER_PATH',
149
+ ffmpeg: 'AGENTVIBES_FFMPEG_PATH',
150
+ ffprobe: 'AGENTVIBES_FFPROBE_PATH',
151
+ config_dir: 'AGENTVIBES_CONFIG_DIR',
152
+ data_dir: 'AGENTVIBES_DATA_DIR',
153
+ cache_dir: 'AGENTVIBES_CACHE_DIR',
154
+ voice_dir: 'AGENTVIBES_VOICE_DIR',
155
+ };
156
+
157
+ // ─── Binary Validation ────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Validate that a candidate binary path is a real, executable, working binary.
161
+ * @returns {{ valid: boolean, reason?: string }}
162
+ */
163
+ export function validateBinary(binaryPath, binaryName) {
164
+ let stat;
165
+ try {
166
+ stat = fs.statSync(binaryPath);
167
+ } catch {
168
+ return { valid: false, reason: 'not_found' };
169
+ }
170
+
171
+ if (!stat.isFile()) return { valid: false, reason: 'not_a_file' };
172
+
173
+ if (process.platform !== 'win32') {
174
+ try {
175
+ fs.accessSync(binaryPath, fs.constants.X_OK);
176
+ } catch {
177
+ return { valid: false, reason: 'not_executable' };
178
+ }
179
+ }
180
+
181
+ // Smoke test: binary must respond to --version (or -version for ffmpeg/ffprobe)
182
+ const versionFlag = (binaryName === 'ffmpeg' || binaryName === 'ffprobe') ? '-version' : '--version';
183
+ try {
184
+ execFileSync(binaryPath, [versionFlag], { stdio: 'pipe', timeout: 3000 });
185
+ return { valid: true };
186
+ } catch {
187
+ return { valid: false, reason: 'version_check_failed' };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Find binary using which (POSIX) or where (Windows).
193
+ * Returns the realpath of the first result, or null.
194
+ */
195
+ export function whichBinary(name) {
196
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
197
+ try {
198
+ const result = execFileSync(cmd, [name], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
199
+ const first = result.trim().split('\n')[0].trim();
200
+ if (!first) return null;
201
+ // Resolve symlinks to get the real binary path
202
+ return fs.realpathSync(first);
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ // ─── Binary Resolution (4-step contract) ─────────────────────────────────────
209
+
210
+ /**
211
+ * Resolve a binary following the contract resolution order.
212
+ * Throws a structured error if resolution fails.
213
+ *
214
+ * @param {'piper'|'ffmpeg'|'ffprobe'} binaryName
215
+ * @returns {{ path: string, source: string }}
216
+ */
217
+ export function resolveBinary(binaryName) {
218
+ const platformId = detectPlatform();
219
+ const envVar = ENV_VARS[binaryName];
220
+ const tried = [];
221
+
222
+ // Step 1: ENV_OVERRIDE — if set, it's absolute. Invalid = hard fail, no fallthrough.
223
+ if (envVar && process.env[envVar]) {
224
+ const overridePath = process.env[envVar];
225
+ const validation = validateBinary(overridePath, binaryName);
226
+ tried.push({ step: 'ENV_OVERRIDE', path: overridePath, ...validation });
227
+ if (!validation.valid) {
228
+ const err = new Error(
229
+ `[AgentVibes] RESOLUTION_FAILURE\n` +
230
+ ` binary: ${binaryName}\n` +
231
+ ` error: ENV_OVERRIDE_INVALID\n` +
232
+ ` platform: ${platformId}\n` +
233
+ ` ${envVar}=${overridePath} (${validation.reason})\n` +
234
+ ` fix: Correct the ${envVar} environment variable or unset it`
235
+ );
236
+ err.code = 'ENV_OVERRIDE_INVALID';
237
+ err.tried = tried;
238
+ throw err;
239
+ }
240
+ return { path: overridePath, source: 'env_override' };
241
+ }
242
+ tried.push({ step: 'ENV_OVERRIDE', path: 'not set' });
243
+
244
+ // Step 2: which/where — respect what the user already has configured
245
+ const whichResult = whichBinary(binaryName);
246
+ if (whichResult) {
247
+ const validation = validateBinary(whichResult, binaryName);
248
+ tried.push({ step: 'WHICH', path: whichResult, ...validation });
249
+ if (validation.valid) {
250
+ return { path: whichResult, source: 'which' };
251
+ }
252
+ // Exists in PATH but failed validation — log and continue to hints
253
+ } else {
254
+ tried.push({ step: 'WHICH', path: 'not found' });
255
+ }
256
+
257
+ // Step 3: Platform hint list — last resort before failure
258
+ const hintFn = BINARY_HINTS[binaryName]?.[platformId];
259
+ if (hintFn) {
260
+ const hints = hintFn();
261
+ for (let i = 0; i < hints.length; i++) {
262
+ const hintPath = hints[i];
263
+ const validation = validateBinary(hintPath, binaryName);
264
+ tried.push({ step: `HINT[${i}]`, path: hintPath, ...validation });
265
+ if (validation.valid) {
266
+ return { path: hintPath, source: `hint[${i}]` };
267
+ }
268
+ }
269
+ }
270
+
271
+ // Step 4: FAIL — structured error with full audit trail
272
+ const triedFormatted = tried
273
+ .map(t => ` - ${t.step.padEnd(12)}: ${t.path}${t.reason ? ` → ${t.reason}` : ''}`)
274
+ .join('\n');
275
+ const err = new Error(
276
+ `[AgentVibes] RESOLUTION_FAILURE\n` +
277
+ ` binary: ${binaryName}\n` +
278
+ ` error: BINARY_NOT_FOUND\n` +
279
+ ` platform: ${platformId}\n` +
280
+ ` tried:\n${triedFormatted}\n` +
281
+ ` fix: Install ${binaryName} or set ${envVar} to the binary path`
282
+ );
283
+ err.code = 'BINARY_NOT_FOUND';
284
+ err.binary = binaryName;
285
+ err.platform = platformId;
286
+ err.tried = tried;
287
+ throw err;
288
+ }
289
+
290
+ // ─── Directory Resolution ─────────────────────────────────────────────────────
291
+
292
+ /**
293
+ * Resolve the voice model directory (never contains tilde on return).
294
+ * Windows: %LOCALAPPDATA%\AgentVibes\voices
295
+ * POSIX: $XDG_DATA_HOME/agentvibes/voices or ~/.local/share/agentvibes/voices
296
+ */
297
+ export function resolveVoiceDir() {
298
+ const override = process.env[ENV_VARS.voice_dir];
299
+ if (override) return path.resolve(override);
300
+ return path.join(resolveDataDir(), 'voices');
301
+ }
302
+
303
+ /**
304
+ * Resolve the data directory.
305
+ * Uses LOCALAPPDATA on Windows, XDG_DATA_HOME or ~/.local/share on POSIX.
306
+ */
307
+ export function resolveDataDir() {
308
+ const override = process.env[ENV_VARS.data_dir];
309
+ if (override) return path.resolve(override);
310
+
311
+ const platformId = detectPlatform();
312
+ if (platformId === 'win32-x64') {
313
+ const localappdata = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
314
+ return path.join(localappdata, 'AgentVibes');
315
+ }
316
+ const xdgData = process.env.XDG_DATA_HOME;
317
+ if (xdgData) return path.join(xdgData, 'agentvibes');
318
+ return path.join(os.homedir(), '.local', 'share', 'agentvibes');
319
+ }
320
+
321
+ /**
322
+ * Resolve the config directory.
323
+ * Uses APPDATA on Windows, XDG_CONFIG_HOME or ~/.config on POSIX.
324
+ */
325
+ export function resolveConfigDir() {
326
+ const override = process.env[ENV_VARS.config_dir];
327
+ if (override) return path.resolve(override);
328
+
329
+ const platformId = detectPlatform();
330
+ if (platformId === 'win32-x64') {
331
+ const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
332
+ return path.join(appdata, 'AgentVibes');
333
+ }
334
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
335
+ if (xdgConfig) return path.join(xdgConfig, 'agentvibes');
336
+ return path.join(os.homedir(), '.config', 'agentvibes');
337
+ }
338
+
339
+ // ─── PATH Augmentation Helper ─────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Return extra PATH directories for the current platform.
343
+ * MCP servers launched by Claude Desktop inherit a sanitized PATH that omits
344
+ * Homebrew (Mac) and pipx (POSIX) locations — this list covers those gaps.
345
+ * Never includes directories already on PATH.
346
+ * @returns {string[]} List of absolute directory paths
347
+ */
348
+ export function getPathAugmentation() {
349
+ const platformId = detectPlatform();
350
+ const extra = [];
351
+
352
+ if (platformId === 'darwin-arm64') {
353
+ extra.push('/opt/homebrew/bin', '/usr/local/bin');
354
+ } else if (platformId === 'darwin-x64') {
355
+ extra.push('/usr/local/bin', '/opt/homebrew/bin');
356
+ }
357
+ // Linux/WSL: ~/.local/bin is the only reliable extra location
358
+ if (platformId === 'linux-x64' || platformId === 'linux-arm64') {
359
+ extra.push(path.join(os.homedir(), '.local', 'bin'));
360
+ }
361
+ // pipx venv — all POSIX platforms
362
+ if (platformId !== 'win32-x64') {
363
+ extra.push(path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin'));
364
+ extra.push(path.join(os.homedir(), '.local', 'bin'));
365
+ }
366
+
367
+ // Deduplicate, preserving order
368
+ return [...new Set(extra)];
369
+ }
@@ -129,6 +129,14 @@ export async function createPreviewListPrompt(inquirer, config) {
129
129
  } catch (e) {
130
130
  // Failed to restore raw mode, but we're exiting anyway
131
131
  }
132
+ // readline.emitKeypressEvents() put stdin into flowing mode; pause it so the
133
+ // open stdin handle no longer keeps the event loop alive after the prompt
134
+ // resolves (otherwise the process lingers until stdin is otherwise closed).
135
+ try {
136
+ process.stdin.pause();
137
+ } catch (e) {
138
+ // stdin may already be closed; nothing more to do
139
+ }
132
140
  }
133
141
  }
134
142
  }
@@ -15,7 +15,7 @@ import os from 'node:os'; // For os.homedir() to prevent HOME injection attacks
15
15
  */
16
16
  function commandExistsInPath(command) {
17
17
  // SECURITY: Use spawnSync instead of execSync+shell to prevent command injection (#126)
18
- const result = spawnSync('which', [command], {
18
+ const result = spawnSync('which', [command], { // NOSONAR
19
19
  encoding: 'utf8',
20
20
  stdio: ['pipe', 'pipe', 'pipe']
21
21
  });
@@ -55,6 +55,10 @@ export async function validateProvider(providerName) {
55
55
  return await validateSopranoInstallation();
56
56
  case 'piper':
57
57
  return await validatePiperInstallation();
58
+ case 'kokoro':
59
+ return await validateKokoroInstallation();
60
+ case 'elevenlabs':
61
+ return await validateElevenLabsInstallation();
58
62
  case 'macos':
59
63
  return await validateMacOSProvider();
60
64
  case 'sapi':
@@ -143,6 +147,66 @@ async function validatePipxProvider(providerName, packageName) {
143
147
  };
144
148
  }
145
149
 
150
+ /**
151
+ * Validate Kokoro TTS installation (kokoro-onnx Python package)
152
+ * @returns {Promise<{installed: boolean, message: string, checkedLocations?: string[], error?: string}>}
153
+ */
154
+ export async function validateKokoroInstallation() {
155
+ const pythonCommands = process.platform === 'win32'
156
+ ? ['py', 'python', 'python3']
157
+ : ['python3', 'python'];
158
+
159
+ for (const pythonCmd of pythonCommands) {
160
+ try {
161
+ const result = spawnSync(pythonCmd, ['-c', 'import kokoro; import soundfile; import numpy'], { // NOSONAR
162
+ encoding: 'utf8',
163
+ stdio: ['pipe', 'pipe', 'pipe'],
164
+ timeout: 8000,
165
+ });
166
+ if (result.status === 0) {
167
+ return { installed: true, message: `Kokoro TTS detected (via ${pythonCmd})`, checkedLocations: [pythonCmd] };
168
+ }
169
+ } catch {
170
+ // try next
171
+ }
172
+ }
173
+
174
+ return {
175
+ installed: false,
176
+ message: 'Kokoro TTS not installed. Run: pip install kokoro-onnx soundfile numpy',
177
+ error: 'KOKORO_NOT_FOUND',
178
+ checkedLocations: pythonCommands,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Validate ElevenLabs configuration (API key presence)
184
+ * @returns {Promise<{installed: boolean, message: string, error?: string}>}
185
+ */
186
+ export async function validateElevenLabsInstallation() {
187
+ // Check environment variable
188
+ if (process.env.ELEVENLABS_API_KEY && process.env.ELEVENLABS_API_KEY.length > 0) {
189
+ return { installed: true, message: 'ElevenLabs API key found in environment' };
190
+ }
191
+
192
+ // Check key file
193
+ const keyFile = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
194
+ if (isSafePathExists(keyFile)) {
195
+ try {
196
+ const key = fs.readFileSync(keyFile, 'utf8').trim();
197
+ if (key.length > 0) {
198
+ return { installed: true, message: 'ElevenLabs API key found in ~/.agentvibes/elevenlabs-key.txt' };
199
+ }
200
+ } catch { /* fall through */ }
201
+ }
202
+
203
+ return {
204
+ installed: false,
205
+ message: 'ElevenLabs API key not set. Set ELEVENLABS_API_KEY env var or save key to ~/.agentvibes/elevenlabs-key.txt',
206
+ error: 'ELEVENLABS_NO_KEY',
207
+ };
208
+ }
209
+
146
210
  /**
147
211
  * Validate Soprano TTS installation
148
212
  * Checks multiple locations: PATH, pipx bin, pipx venv, Python packages
@@ -176,7 +240,7 @@ export async function validateMacOSProvider() {
176
240
 
177
241
  try {
178
242
  // SECURITY: Use spawnSync instead of execSync+shell to prevent command injection (#126)
179
- const result = spawnSync('which', ['say'], {
243
+ const result = spawnSync('which', ['say'], { // NOSONAR
180
244
  encoding: 'utf8',
181
245
  stdio: ['pipe', 'pipe', 'pipe']
182
246
  });
@@ -272,7 +336,7 @@ export async function testProviderRuntime(providerName) {
272
336
  async function testSopranoRuntime() {
273
337
  try {
274
338
  // Try a quick soprano import check
275
- const result = spawnSync('python3', ['-c', 'import soprano; print("OK")'], {
339
+ const result = spawnSync('python3', ['-c', 'import soprano; print("OK")'], { // NOSONAR
276
340
  timeout: 5000,
277
341
  encoding: 'utf8'
278
342
  });
@@ -298,7 +362,7 @@ async function testSopranoRuntime() {
298
362
  */
299
363
  async function testPiperRuntime() {
300
364
  try {
301
- const result = spawnSync('piper', ['--help'], {
365
+ const result = spawnSync('piper', ['--help'], { // NOSONAR
302
366
  encoding: 'utf8',
303
367
  stdio: ['pipe', 'pipe', 'pipe'],
304
368
  timeout: 5000
@@ -323,7 +387,7 @@ async function testPiperRuntime() {
323
387
  */
324
388
  async function testMacOSRuntime() {
325
389
  try {
326
- const result = spawnSync('say', ['-f', '/dev/null'], {
390
+ const result = spawnSync('say', ['-f', '/dev/null'], { // NOSONAR
327
391
  encoding: 'utf8',
328
392
  timeout: 5000,
329
393
  stdio: ['pipe', 'pipe', 'pipe']
@@ -352,6 +416,8 @@ export function getProviderInstallCommand(providerName) {
352
416
  const commands = {
353
417
  soprano: 'pip install soprano-tts',
354
418
  piper: 'pip install piper-tts',
419
+ kokoro: 'pip install kokoro-onnx soundfile numpy',
420
+ elevenlabs: 'export ELEVENLABS_API_KEY=your_key # get free key at elevenlabs.io',
355
421
  // macOS Say and Windows SAPI are built-in, no install needed
356
422
  };
357
423
 
@@ -369,7 +435,9 @@ export async function attemptProviderInstallation(providerName) {
369
435
  const providers = {
370
436
  soprano: 'soprano-tts',
371
437
  piper: 'piper-tts',
438
+ kokoro: 'kokoro-onnx',
372
439
  'windows-piper': 'piper-windows-exe'
440
+ // elevenlabs requires API key, not pip install — handled separately
373
441
  };
374
442
 
375
443
  const pkgName = providers[providerName];
@@ -394,7 +462,7 @@ export async function attemptProviderInstallation(providerName) {
394
462
  // Strategy 1: Try regular pip install (using spawnSync for correct API usage)
395
463
  try {
396
464
  console.log(` Attempting: pip install ${pkgName}...`);
397
- const result = spawnSync('pip', ['install', pkgName], {
465
+ const result = spawnSync('pip', ['install', pkgName], { // NOSONAR
398
466
  stdio: 'inherit',
399
467
  timeout: 60000
400
468
  });
@@ -421,7 +489,7 @@ export async function attemptProviderInstallation(providerName) {
421
489
  // Strategy 2: Try pipx install (recommended for PEP 668 protection)
422
490
  try {
423
491
  console.log(` Attempting: pipx install ${pkgName}...`);
424
- const result = spawnSync('pipx', ['install', pkgName], {
492
+ const result = spawnSync('pipx', ['install', pkgName], { // NOSONAR
425
493
  stdio: 'inherit',
426
494
  timeout: 60000
427
495
  });
@@ -449,7 +517,7 @@ export async function attemptProviderInstallation(providerName) {
449
517
  if (isWindows) {
450
518
  try {
451
519
  console.log(` Attempting: py -m pip install ${pkgName}...`);
452
- const result = spawnSync('py', ['-m', 'pip', 'install', pkgName], {
520
+ const result = spawnSync('py', ['-m', 'pip', 'install', pkgName], { // NOSONAR
453
521
  stdio: 'inherit',
454
522
  timeout: 60000
455
523
  });
@@ -487,7 +555,7 @@ export async function attemptProviderInstallation(providerName) {
487
555
  function getPackageInfo(pkgName) {
488
556
  try {
489
557
  // Use spawnSync with array args (security: correct API usage per CLAUDE.md)
490
- const result = spawnSync('pip', ['show', pkgName], {
558
+ const result = spawnSync('pip', ['show', pkgName], { // NOSONAR
491
559
  encoding: 'utf8',
492
560
  timeout: 10000,
493
561
  stdio: ['pipe', 'pipe', 'pipe']
@@ -538,6 +606,8 @@ export function getProviderDisplayName(providerName) {
538
606
  const names = {
539
607
  soprano: 'Soprano TTS',
540
608
  piper: 'Piper TTS',
609
+ kokoro: 'Kokoro TTS',
610
+ elevenlabs: 'ElevenLabs',
541
611
  macos: 'macOS Say',
542
612
  sapi: 'Windows SAPI',
543
613
  'windows-sapi': 'Windows SAPI',
@@ -119,7 +119,7 @@ fi
119
119
 
120
120
  # Validate provider
121
121
  case "$PROVIDER" in
122
- piper|soprano|macos|windows-sapi) ;;
122
+ piper|soprano|macos|windows-sapi|kokoro|elevenlabs) ;;
123
123
  *) PROVIDER="piper" ;;
124
124
  esac
125
125
 
@@ -226,6 +226,10 @@ if [[ "${AGENTVIBES_DEBUG:-0}" == "1" ]]; then
226
226
  fi
227
227
 
228
228
  echo "🎵 Playing via AgentVibes: ${TEXT:0:50}..." >&2
229
- bash "$PLAY_TTS" "$TEXT" "$VOICE"
229
+ # AGENTVIBES_FORCE_PROVIDER lets the sender specify which engine the receiver
230
+ # should use (e.g. kokoro, elevenlabs). The receiver's tts-provider.txt takes
231
+ # precedence if already set to a non-default value; FORCE_PROVIDER is the
232
+ # fallback when the receiver has no local preference configured.
233
+ AGENTVIBES_FORCE_PROVIDER="$PROVIDER" bash "$PLAY_TTS" "$TEXT" "$VOICE"
230
234
 
231
235
  exit 0