agentvibes 5.7.7 → 5.10.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.
Files changed (46) hide show
  1. package/.agentvibes/config.json +0 -2
  2. package/.claude/config/audio-effects.cfg +4 -4
  3. package/.claude/config/background-music-enabled.txt +1 -0
  4. package/.claude/github-star-reminder.txt +1 -1
  5. package/.claude/hooks/play-tts-piper.sh +20 -13
  6. package/.claude/hooks/play-tts-ssh-remote.sh +2 -2
  7. package/.claude/hooks/voice-manager.sh +6 -0
  8. package/.claude/hooks-windows/audio-cache-utils.ps1 +119 -119
  9. package/.claude/hooks-windows/bmad-speak.ps1 +9 -38
  10. package/.claude/hooks-windows/play-tts-soprano.ps1 +13 -2
  11. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +22 -16
  12. package/.mcp.json +13 -9
  13. package/README.md +33 -2
  14. package/RELEASE_NOTES.md +80 -0
  15. package/mcp-server/server.py +17 -7
  16. package/package.json +2 -2
  17. package/src/commands/install-mcp.js +270 -16
  18. package/src/console/app.js +3 -3
  19. package/src/console/audio-env.js +4 -1
  20. package/src/console/tabs/agents-tab.js +89 -66
  21. package/src/console/tabs/music-tab.js +4 -3
  22. package/src/console/tabs/receiver-tab.js +13 -13
  23. package/src/console/tabs/settings-tab.js +2 -2
  24. package/src/console/tabs/setup-tab.js +291 -47
  25. package/src/console/tabs/voices-tab.js +17 -5
  26. package/src/console/widgets/personality-picker.js +2 -2
  27. package/src/console/widgets/reverb-picker.js +1 -1
  28. package/src/installer.js +32 -27
  29. package/src/services/provider-service.js +1 -1
  30. package/src/services/tts-engine-service.js +2 -2
  31. package/src/utils/audio-duration-validator.js +2 -2
  32. package/src/utils/list-formatter.js +9 -3
  33. package/src/utils/platform-resolver.js +369 -0
  34. package/src/utils/provider-validator.js +9 -9
  35. package/.agentvibes/install-manifest.json +0 -442
  36. package/.claude/config/background-music-position.txt +0 -27
  37. package/.claude/config/background-music-volume.txt +0 -1
  38. package/.claude/config/background-music.cfg +0 -1
  39. package/.claude/config/background-music.txt +0 -1
  40. package/.claude/config/reverb-level.txt +0 -1
  41. package/.claude/config/tts-speech-rate.txt +0 -1
  42. package/.claude/config/tts-verbosity.txt +0 -1
  43. package/.claude/hooks/bmad-party-manager.sh +0 -225
  44. package/.claude/hooks/stop.sh +0 -38
  45. package/.claude/piper-voices-dir.txt +0 -1
  46. /package/.claude/audio/tracks/{CelestialVelvet.mp3 → celestial_velvet.mp3} +0 -0
package/src/installer.js CHANGED
@@ -383,14 +383,14 @@ function isPiperInstalled() {
383
383
  }
384
384
  // Also check PATH (e.g. pip-installed piper)
385
385
  try {
386
- execSync('where piper.exe', { stdio: 'pipe', timeout: 3000 });
386
+ execSync('where piper.exe', { stdio: 'pipe', timeout: 3000 }); // NOSONAR
387
387
  return true;
388
388
  } catch (e) {
389
389
  return false;
390
390
  }
391
391
  }
392
392
  try {
393
- execSync('which piper', {
393
+ execSync('which piper', { // NOSONAR
394
394
  stdio: 'pipe',
395
395
  timeout: 3000
396
396
  });
@@ -406,7 +406,7 @@ function isPiperInstalled() {
406
406
  */
407
407
  function isSopranoInstalled() {
408
408
  try {
409
- execSync('which soprano-tts || which soprano-webui', {
409
+ execSync('which soprano-tts || which soprano-webui', { // NOSONAR
410
410
  stdio: 'pipe',
411
411
  timeout: 3000
412
412
  });
@@ -415,7 +415,7 @@ function isSopranoInstalled() {
415
415
  // On Windows, 'which' may not find Python scripts; try 'py -m pip show' as fallback
416
416
  if (isNativeWindows()) {
417
417
  try {
418
- const result = spawnSync('py', ['-m', 'pip', 'show', 'soprano-tts'], {
418
+ const result = spawnSync('py', ['-m', 'pip', 'show', 'soprano-tts'], { // NOSONAR
419
419
  encoding: 'utf8',
420
420
  stdio: ['pipe', 'pipe', 'pipe'],
421
421
  timeout: 10000
@@ -454,7 +454,7 @@ async function playVoiceSample(voiceName, provider) {
454
454
  // Play using sox/aplay - use spawn for non-blocking playback
455
455
  try {
456
456
  // Play using aplay directly (no shell interpolation — prevents command injection)
457
- const player = spawn('aplay', [sampleFile], {
457
+ const player = spawn('aplay', [sampleFile], { // NOSONAR
458
458
  detached: false,
459
459
  stdio: 'ignore'
460
460
  });
@@ -470,7 +470,7 @@ async function playVoiceSample(voiceName, provider) {
470
470
  if (isPiperProvider(provider) && isPiperInstalled()) {
471
471
  const text = `Hi, I'm ${voiceName.split('-')[1] || 'Piper'}`;
472
472
  // Use bash -c with positional args to prevent command injection via text/voiceName
473
- spawnSync('bash', ['-c', 'echo "$1" | piper --model "$2" --output_raw | aplay -r 22050 -f S16_LE -t raw -', '_', text, voiceName], {
473
+ spawnSync('bash', ['-c', 'echo "$1" | piper --model "$2" --output_raw | aplay -r 22050 -f S16_LE -t raw -', '_', text, voiceName], { // NOSONAR
474
474
  stdio: 'inherit',
475
475
  timeout: 15000
476
476
  });
@@ -492,7 +492,7 @@ async function playVoiceSample(voiceName, provider) {
492
492
  fsSync.writeFileSync(tempFile, Buffer.from(audio));
493
493
  try {
494
494
  // Use spawnSync with argument array to prevent command injection
495
- spawnSync('aplay', [tempFile], { stdio: 'pipe', timeout: 5000 });
495
+ spawnSync('aplay', [tempFile], { stdio: 'pipe', timeout: 5000 }); // NOSONAR
496
496
  } finally {
497
497
  fsSync.unlinkSync(tempFile);
498
498
  }
@@ -531,7 +531,7 @@ async function startSopranoServer() {
531
531
  console.log(chalk.gray('🚀 Starting Soprano TTS server...'));
532
532
 
533
533
  // Start soprano-webui in background
534
- const sopranoProcess = spawn('soprano-webui', ['--port', '7860'], {
534
+ const sopranoProcess = spawn('soprano-webui', ['--port', '7860'], { // NOSONAR
535
535
  detached: true,
536
536
  stdio: 'ignore'
537
537
  });
@@ -569,7 +569,7 @@ async function detectSystemCapabilities() {
569
569
  try {
570
570
  // Detect NVIDIA GPU
571
571
  try {
572
- execSync('nvidia-smi --query-gpu=name --format=csv,noheader', {
572
+ execSync('nvidia-smi --query-gpu=name --format=csv,noheader', { // NOSONAR
573
573
  stdio: 'pipe',
574
574
  timeout: 5000 // 5 second timeout
575
575
  });
@@ -580,7 +580,7 @@ async function detectSystemCapabilities() {
580
580
 
581
581
  // Detect total RAM (in MB)
582
582
  if (isMacOS) {
583
- const output = execSync('sysctl hw.memsize', {
583
+ const output = execSync('sysctl hw.memsize', { // NOSONAR
584
584
  encoding: 'utf8',
585
585
  timeout: 3000 // 3 second timeout
586
586
  });
@@ -594,7 +594,7 @@ async function detectSystemCapabilities() {
594
594
  }
595
595
  totalRAM = Math.floor(bytes / (1024 * 1024));
596
596
  } else {
597
- const output = execSync('cat /proc/meminfo | grep MemTotal', {
597
+ const output = execSync('cat /proc/meminfo | grep MemTotal', { // NOSONAR
598
598
  encoding: 'utf8',
599
599
  timeout: 3000 // 3 second timeout
600
600
  });
@@ -679,12 +679,12 @@ function checkAudioDevices() {
679
679
  if (process.platform === 'linux') {
680
680
  try {
681
681
  // Try aplay first (ALSA)
682
- execSync('aplay -l 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' });
682
+ execSync('aplay -l 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' }); // NOSONAR
683
683
  return true;
684
684
  } catch (e) {
685
685
  // Try paplay (PulseAudio)
686
686
  try {
687
- execSync('paplay --version 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' });
687
+ execSync('paplay --version 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' }); // NOSONAR
688
688
  return true;
689
689
  } catch (e2) {
690
690
  return false;
@@ -936,7 +936,7 @@ async function handleSystemDependenciesPage() {
936
936
 
937
937
  if (piperInstalled) {
938
938
  try {
939
- const piperPath = execSync('which piper 2>/dev/null', { encoding: 'utf8' }).trim();
939
+ const piperPath = execSync('which piper 2>/dev/null', { encoding: 'utf8' }).trim(); // NOSONAR
940
940
  depContent += chalk.green('✓ Piper TTS (offline voice synthesis)\n');
941
941
  depContent += chalk.gray(` ${piperPath}\n`);
942
942
  } catch (e) {
@@ -947,9 +947,9 @@ async function handleSystemDependenciesPage() {
947
947
  try {
948
948
  let sopranoPath = '';
949
949
  try {
950
- sopranoPath = execSync('which soprano-tts 2>/dev/null', { encoding: 'utf8' }).trim();
950
+ sopranoPath = execSync('which soprano-tts 2>/dev/null', { encoding: 'utf8' }).trim(); // NOSONAR
951
951
  } catch (e) {
952
- sopranoPath = execSync('which soprano-webui 2>/dev/null', { encoding: 'utf8' }).trim();
952
+ sopranoPath = execSync('which soprano-webui 2>/dev/null', { encoding: 'utf8' }).trim(); // NOSONAR
953
953
  }
954
954
  depContent += chalk.green('✓ Soprano TTS (premium quality)\n');
955
955
  depContent += chalk.gray(` ${sopranoPath}\n`);
@@ -2244,7 +2244,7 @@ async function collectConfiguration(options = {}) {
2244
2244
  const content = await fs.readFile(filePath, 'utf-8');
2245
2245
 
2246
2246
  // Extract description from frontmatter
2247
- const descMatch = content.match(/^description:\s*(.+)$/m);
2247
+ const descMatch = content.match(/^description:\s*(.+)$/m); // NOSONAR
2248
2248
  const description = descMatch ? descMatch[1].trim() : '';
2249
2249
 
2250
2250
  personalities.push({
@@ -2958,7 +2958,7 @@ function getReleaseInfoBoxen() {
2958
2958
  .replace(/^#{1,6}\s*/, '') // ## headings
2959
2959
  .replace(/\*\*([^*]+)\*\*/g, '$1') // **bold**
2960
2960
  .replace(/`([^`]+)`/g, '$1') // `code`
2961
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [text](url)
2961
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [text](url) // NOSONAR
2962
2962
 
2963
2963
  // Render: heading in cyan, bullets as gray, skip blank/hr lines at end
2964
2964
  return sectionLines
@@ -3007,7 +3007,7 @@ async function playWelcomeDemo(targetDir, spinner, options = {}) {
3007
3007
  if (isNativeWindows()) {
3008
3008
  // Escape single quotes to prevent PowerShell injection (double them per PS escaping rules)
3009
3009
  const safeAudioPath = welcomeDemoAudio.replace(/'/g, "''");
3010
- const audioProcess = spawn('powershell', [
3010
+ const audioProcess = spawn('powershell', [ // NOSONAR
3011
3011
  '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
3012
3012
  `(New-Object System.Media.SoundPlayer '${safeAudioPath}').PlaySync()`
3013
3013
  ], { detached: true, stdio: 'ignore' });
@@ -3019,15 +3019,15 @@ async function playWelcomeDemo(targetDir, spinner, options = {}) {
3019
3019
  let audioPlayer = null;
3020
3020
 
3021
3021
  try {
3022
- execFileSync('which', ['paplay'], { stdio: 'pipe' });
3022
+ execFileSync('which', ['paplay'], { stdio: 'pipe' }); // NOSONAR
3023
3023
  audioPlayer = 'paplay';
3024
3024
  } catch {
3025
3025
  try {
3026
- execFileSync('which', ['afplay'], { stdio: 'pipe' });
3026
+ execFileSync('which', ['afplay'], { stdio: 'pipe' }); // NOSONAR
3027
3027
  audioPlayer = 'afplay';
3028
3028
  } catch {
3029
3029
  try {
3030
- execFileSync('which', ['mpv'], { stdio: 'pipe' });
3030
+ execFileSync('which', ['mpv'], { stdio: 'pipe' }); // NOSONAR
3031
3031
  audioPlayer = 'mpv';
3032
3032
  } catch {}
3033
3033
  }
@@ -4907,7 +4907,7 @@ async function executeMigrationScript(migrationScript, targetDir, spinner) {
4907
4907
 
4908
4908
  // Execute migration script using execFileSync to prevent command injection
4909
4909
  // Uses top-level import of execFileSync (ESM-compatible, no require())
4910
- execFileSync('bash', [migrationScript], { cwd: targetDir, stdio: 'pipe' });
4910
+ execFileSync('bash', [migrationScript], { cwd: targetDir, stdio: 'pipe' }); // NOSONAR
4911
4911
 
4912
4912
  spinner.succeed(chalk.green('✓ Configuration migrated to .agentvibes/'));
4913
4913
  console.log(chalk.gray(' Old locations: .claude/config/, .claude/plugins/'));
@@ -5298,7 +5298,7 @@ async function restartWatcherIfInstalled(homeDirOverride) {
5298
5298
  const { spawnSync, spawn } = require('child_process');
5299
5299
 
5300
5300
  // Kill old watcher — use array args to avoid quoting issues
5301
- spawnSync('powershell.exe', [
5301
+ spawnSync('powershell.exe', [ // NOSONAR
5302
5302
  '-NoProfile', '-Command',
5303
5303
  'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like \'*tts-watcher.ps1*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }'
5304
5304
  ], { stdio: 'ignore', timeout: 8000 });
@@ -5315,9 +5315,9 @@ async function restartWatcherIfInstalled(homeDirOverride) {
5315
5315
  // Restart via VBS launcher (hidden, no console flash) or fall back to direct spawn
5316
5316
  try {
5317
5317
  await fs.access(vbsLauncher);
5318
- spawnSync('wscript.exe', [vbsLauncher], { stdio: 'ignore' });
5318
+ spawnSync('wscript.exe', [vbsLauncher], { stdio: 'ignore' }); // NOSONAR
5319
5319
  } catch {
5320
- const ps = spawn('powershell.exe', [
5320
+ const ps = spawn('powershell.exe', [ // NOSONAR
5321
5321
  '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', watcherDest
5322
5322
  ], { detached: true, stdio: 'ignore' });
5323
5323
  ps.unref();
@@ -5782,7 +5782,7 @@ Troubleshooting:
5782
5782
  const validReverb = ['light', 'medium', 'heavy', 'cathedral'];
5783
5783
  if (validReverb.includes(selectedReverb)) {
5784
5784
  try {
5785
- execFileSync('bash', [effectsManagerPath, 'set-reverb', selectedReverb, 'default'], { stdio: 'pipe' });
5785
+ execFileSync('bash', [effectsManagerPath, 'set-reverb', selectedReverb, 'default'], { stdio: 'pipe' }); // NOSONAR
5786
5786
  } catch {
5787
5787
  // Reverb setting failed — non-fatal
5788
5788
  }
@@ -6721,4 +6721,9 @@ export {
6721
6721
  // Manifest utilities (used by tests and external tooling)
6722
6722
  getProjectManifestPath, getGlobalManifestPath,
6723
6723
  loadManifest, saveManifest, computeFileHash, manifestSafeCopy, removeManifestFiles,
6724
+ // Pure helper functions exported for testing
6725
+ isPiperProvider, supportsEmoji, getPersonalityIcon,
6726
+ detectEnvironment, createPageHeaderFooter, buildNavigationChoices, handleNavigationAction, getPageTitle,
6727
+ getUserShell, showWelcome, getReleaseInfoBoxen, generateActivationInstructions,
6728
+ collectConfiguration,
6724
6729
  };
@@ -132,7 +132,7 @@ export class ProviderService {
132
132
  */
133
133
  _isAvailable(binary) {
134
134
  try {
135
- execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 });
135
+ execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
136
136
  return true;
137
137
  } catch {
138
138
  return false;
@@ -49,10 +49,10 @@ export function checkEngineInstalled(engineId) {
49
49
  (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
50
50
  if (localAppData && fs.existsSync(path.join(localAppData, 'Programs', 'Piper', 'piper.exe'))) return true;
51
51
  }
52
- execFileSync('where', [binary], { stdio: 'ignore', timeout: 2000 });
52
+ execFileSync('where', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
53
53
  return true;
54
54
  }
55
- execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 });
55
+ execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
56
56
  return true;
57
57
  } catch {
58
58
  // try next binary
@@ -61,7 +61,7 @@ export async function getAudioDuration(filePath) {
61
61
  }
62
62
 
63
63
  // Check if ffprobe is available
64
- const checkFFprobe = spawn('which', ['ffprobe']);
64
+ const checkFFprobe = spawn('which', ['ffprobe']); // NOSONAR
65
65
  let ffprobeExists = false;
66
66
 
67
67
  checkFFprobe.on('close', (code) => {
@@ -78,7 +78,7 @@ export async function getAudioDuration(filePath) {
78
78
 
79
79
  // Use ffprobe to get duration (part of ffmpeg)
80
80
  // SECURITY: Use spawn with args array to prevent command injection
81
- const ffprobe = spawn('ffprobe', [
81
+ const ffprobe = spawn('ffprobe', [ // NOSONAR
82
82
  '-v', 'error',
83
83
  '-show_entries', 'format=duration',
84
84
  '-of', 'default=noprint_wrappers=1:nokey=1',
@@ -71,9 +71,15 @@ export function formatVoicesList(voices, options = {}) {
71
71
  } = options;
72
72
 
73
73
  if (voices.length === 0) {
74
- const content = chalk.yellow('No voices found') + '\n\n' +
75
- chalk.gray('Download voices with:\n') +
76
- chalk.cyan(' /agent-vibes:provider download <voice-name>');
74
+ const content =
75
+ chalk.yellow('No voices downloaded yet') + '\n\n' +
76
+ chalk.white('AgentVibes includes 900+ free voices.\n') +
77
+ chalk.white('Get started with a default voice:\n\n') +
78
+ chalk.cyan(' /agent-vibes:add\n\n') +
79
+ chalk.gray('Or browse the full voice library:\n') +
80
+ chalk.cyan(' /agent-vibes:list\n\n') +
81
+ chalk.dim('Piper installed but voices missing? Run:\n') +
82
+ chalk.dim(' /agent-vibes:provider download en_US-lessac-medium');
77
83
 
78
84
  return boxen(content, {
79
85
  padding: 1,
@@ -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
+ }