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
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
  }
@@ -3190,7 +3190,9 @@ function execScript(scriptPath, options = {}) {
3190
3190
  // Instead, directly execute the script file without sourcing shell config
3191
3191
  // The script itself will be executed in a clean environment
3192
3192
  // Note: This means shell aliases/functions won't be available, but that's safer
3193
- return execFileSync(scriptFile, args, {
3193
+ // S8701: scriptFile is validated to live under .claude/hooks (above), args are
3194
+ // passed as an array and shell:false disables shell interpretation. Risk handled.
3195
+ return execFileSync(scriptFile, args, { // NOSONAR
3194
3196
  ...options,
3195
3197
  shell: false // Don't use shell to avoid injection risks
3196
3198
  });
@@ -3598,7 +3600,7 @@ function shouldIncludeHookFile(file, stat) {
3598
3600
  !file.startsWith('.');
3599
3601
  }
3600
3602
  return stat.isFile() &&
3601
- (file.endsWith('.sh') || file === 'hooks.json') &&
3603
+ (file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json') &&
3602
3604
  !file.includes('prepare-release') &&
3603
3605
  !file.startsWith('.');
3604
3606
  }
@@ -4349,6 +4351,47 @@ async function installPluginManifest(targetDir, spinner) {
4349
4351
  }
4350
4352
  }
4351
4353
 
4354
+ /**
4355
+ * Prompt the user to optionally download the LibriTTS 900+ voice library.
4356
+ * Skipped silently if: options.yes, libritts already downloaded, or script missing.
4357
+ */
4358
+ async function offerLibriTTSDownload(piperDownloadPath, options) {
4359
+ if (options.yes) return;
4360
+ if (!fsSync.existsSync(piperDownloadPath)) return;
4361
+
4362
+ const voicesDir = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'piper-voices');
4363
+ const librittsAlreadyInstalled = fsSync.existsSync(path.join(voicesDir, 'en_US-libritts-high.onnx'));
4364
+ if (librittsAlreadyInstalled) return;
4365
+
4366
+ console.log(chalk.cyan('\n🎙️ Want 900+ voice variations?\n'));
4367
+ console.log(chalk.gray(' The LibriTTS model includes 904 named speakers (Ryan, Sarah, Joe, and more).'));
4368
+ console.log(chalk.gray(' Browse them with /agent-vibes:list after install.'));
4369
+ console.log(chalk.gray(' Download size: ~114MB\n'));
4370
+
4371
+ const { installLibriTTS } = await inquirer.prompt([{
4372
+ type: 'confirm',
4373
+ name: 'installLibriTTS',
4374
+ message: 'Download LibriTTS voice library? (optional)',
4375
+ default: false,
4376
+ }]);
4377
+
4378
+ if (!installLibriTTS) {
4379
+ console.log(chalk.gray(' Skipped. Run later: ~/.claude/hooks/piper-download-voices.sh --libritts\n'));
4380
+ return;
4381
+ }
4382
+
4383
+ try {
4384
+ execScript(`${piperDownloadPath} --libritts --yes`, {
4385
+ stdio: 'inherit',
4386
+ env: process.env
4387
+ });
4388
+ console.log(chalk.green('\n✅ LibriTTS voices downloaded! Browse with /agent-vibes:list\n'));
4389
+ } catch {
4390
+ console.log(chalk.yellow('\n⚠️ LibriTTS download failed.'));
4391
+ console.log(chalk.gray(' Run later: ~/.claude/hooks/piper-download-voices.sh --libritts\n'));
4392
+ }
4393
+ }
4394
+
4352
4395
  /**
4353
4396
  * Check if Piper is installed and optionally install it
4354
4397
  * @param {string} targetDir - Target installation directory
@@ -4374,6 +4417,8 @@ async function checkAndInstallPiper(targetDir, options) {
4374
4417
 
4375
4418
  if (hasVoices) {
4376
4419
  console.log(chalk.green(` Found ${voiceFiles.length} voice model(s)\n`));
4420
+ const piperDownloadPathEarly = path.join(targetDir, '.claude', 'hooks', 'piper-download-voices.sh');
4421
+ await offerLibriTTSDownload(piperDownloadPathEarly, options);
4377
4422
  return;
4378
4423
  }
4379
4424
  }
@@ -4402,6 +4447,7 @@ async function checkAndInstallPiper(targetDir, options) {
4402
4447
  console.log(chalk.cyan(` ${piperDownloadPath}\n`));
4403
4448
  }
4404
4449
 
4450
+ await offerLibriTTSDownload(piperDownloadPath, options);
4405
4451
  return;
4406
4452
  } catch {
4407
4453
  console.log(chalk.yellow('⚠️ Piper TTS binary not detected\n'));
@@ -4591,6 +4637,43 @@ async function checkAndInstallPiperWindows(targetDir, options) {
4591
4637
  console.log(chalk.yellow('You can download voices manually later.\n'));
4592
4638
  }
4593
4639
  }
4640
+
4641
+ // Offer LibriTTS 900+ voices (Windows — downloaded via Node directly)
4642
+ if (!options.yes && !fsSync.existsSync(path.join(voicesDir, 'en_US-libritts-high.onnx'))) {
4643
+ console.log(chalk.cyan('\n🎙️ Want 900+ voice variations?\n'));
4644
+ console.log(chalk.gray(' The LibriTTS model includes 904 named speakers (Ryan, Sarah, Joe, and more).'));
4645
+ console.log(chalk.gray(' Browse them with /agent-vibes:list after install.'));
4646
+ console.log(chalk.gray(' Download size: ~114MB\n'));
4647
+
4648
+ const { installLibriTTSWin } = await inquirer.prompt([{
4649
+ type: 'confirm',
4650
+ name: 'installLibriTTSWin',
4651
+ message: 'Download LibriTTS voice library? (optional)',
4652
+ default: false,
4653
+ }]);
4654
+
4655
+ if (installLibriTTSWin) {
4656
+ const librittsVoices = [
4657
+ { name: 'en_US-libritts-high', path: 'en/en_US/libritts/high' },
4658
+ { name: 'en_US-libritts_r-medium', path: 'en/en_US/libritts_r/medium' },
4659
+ ];
4660
+ const piperBase = 'https://huggingface.co/rhasspy/piper-voices/resolve/main';
4661
+ await fs.mkdir(voicesDir, { recursive: true });
4662
+ for (const v of librittsVoices) {
4663
+ spinner.start(`Downloading ${v.name}...`);
4664
+ try {
4665
+ await downloadFile(`${piperBase}/${v.path}/${v.name}.onnx`, path.join(voicesDir, `${v.name}.onnx`));
4666
+ await downloadFile(`${piperBase}/${v.path}/${v.name}.onnx.json`, path.join(voicesDir, `${v.name}.onnx.json`));
4667
+ spinner.succeed(chalk.green(`Downloaded ${v.name}`));
4668
+ } catch (err) {
4669
+ spinner.fail(chalk.yellow(`Failed to download ${v.name}: ${err.message}`));
4670
+ }
4671
+ }
4672
+ console.log(chalk.green('\n✅ LibriTTS voices ready! Browse with /agent-vibes:list\n'));
4673
+ } else {
4674
+ console.log(chalk.gray(' Skipped. Download later via: npx agentvibes → Voices tab → Install BMAD Voices\n'));
4675
+ }
4676
+ }
4594
4677
  }
4595
4678
 
4596
4679
  /**
@@ -4907,7 +4990,7 @@ async function executeMigrationScript(migrationScript, targetDir, spinner) {
4907
4990
 
4908
4991
  // Execute migration script using execFileSync to prevent command injection
4909
4992
  // Uses top-level import of execFileSync (ESM-compatible, no require())
4910
- execFileSync('bash', [migrationScript], { cwd: targetDir, stdio: 'pipe' });
4993
+ execFileSync('bash', [migrationScript], { cwd: targetDir, stdio: 'pipe' }); // NOSONAR
4911
4994
 
4912
4995
  spinner.succeed(chalk.green('✓ Configuration migrated to .agentvibes/'));
4913
4996
  console.log(chalk.gray(' Old locations: .claude/config/, .claude/plugins/'));
@@ -5298,7 +5381,7 @@ async function restartWatcherIfInstalled(homeDirOverride) {
5298
5381
  const { spawnSync, spawn } = require('child_process');
5299
5382
 
5300
5383
  // Kill old watcher — use array args to avoid quoting issues
5301
- spawnSync('powershell.exe', [
5384
+ spawnSync('powershell.exe', [ // NOSONAR
5302
5385
  '-NoProfile', '-Command',
5303
5386
  'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like \'*tts-watcher.ps1*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }'
5304
5387
  ], { stdio: 'ignore', timeout: 8000 });
@@ -5315,9 +5398,9 @@ async function restartWatcherIfInstalled(homeDirOverride) {
5315
5398
  // Restart via VBS launcher (hidden, no console flash) or fall back to direct spawn
5316
5399
  try {
5317
5400
  await fs.access(vbsLauncher);
5318
- spawnSync('wscript.exe', [vbsLauncher], { stdio: 'ignore' });
5401
+ spawnSync('wscript.exe', [vbsLauncher], { stdio: 'ignore' }); // NOSONAR
5319
5402
  } catch {
5320
- const ps = spawn('powershell.exe', [
5403
+ const ps = spawn('powershell.exe', [ // NOSONAR
5321
5404
  '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', watcherDest
5322
5405
  ], { detached: true, stdio: 'ignore' });
5323
5406
  ps.unref();
@@ -5782,7 +5865,7 @@ Troubleshooting:
5782
5865
  const validReverb = ['light', 'medium', 'heavy', 'cathedral'];
5783
5866
  if (validReverb.includes(selectedReverb)) {
5784
5867
  try {
5785
- execFileSync('bash', [effectsManagerPath, 'set-reverb', selectedReverb, 'default'], { stdio: 'pipe' });
5868
+ execFileSync('bash', [effectsManagerPath, 'set-reverb', selectedReverb, 'default'], { stdio: 'pipe' }); // NOSONAR
5786
5869
  } catch {
5787
5870
  // Reverb setting failed — non-fatal
5788
5871
  }
@@ -5793,17 +5876,22 @@ Troubleshooting:
5793
5876
  if (userConfig.hermes?.enabled) {
5794
5877
  try {
5795
5878
  const hermesHooksDir = path.join(userConfig.hermes.hermesHome, 'hooks', 'agentvibes-tts');
5796
- // Validate that hermesHome stays within expected bounds (no path traversal)
5879
+ // Validate that hermesHome stays within expected bounds (no path traversal).
5880
+ // Use a trailing path.sep so a sibling like ".../hermes-evil" can't satisfy
5881
+ // the prefix check against ".../hermes".
5797
5882
  const resolvedHermesHome = path.resolve(userConfig.hermes.hermesHome);
5798
5883
  const resolvedHooksDir = path.resolve(hermesHooksDir);
5799
- if (!resolvedHooksDir.startsWith(resolvedHermesHome)) {
5884
+ if (resolvedHooksDir !== resolvedHermesHome &&
5885
+ !resolvedHooksDir.startsWith(resolvedHermesHome + path.sep)) {
5800
5886
  throw new Error('Invalid Hermes hooks path — possible path traversal');
5801
5887
  }
5802
- await fs.mkdir(hermesHooksDir, { recursive: true, mode: 0o700 });
5888
+ // S8707: writes target resolvedHooksDir, validated above to stay within
5889
+ // the user-configured hermesHome. Risk handled.
5890
+ await fs.mkdir(hermesHooksDir, { recursive: true, mode: 0o700 }); // NOSONAR
5803
5891
 
5804
5892
  // HOOK.yaml
5805
5893
  const hookYaml = `name: agentvibes-tts\ndescription: Send agent responses to AgentVibes TTS remotely\nevents:\n - agent:end\n`;
5806
- await fs.writeFile(path.join(hermesHooksDir, 'HOOK.yaml'), hookYaml, { mode: 0o600 });
5894
+ await fs.writeFile(path.join(hermesHooksDir, 'HOOK.yaml'), hookYaml, { mode: 0o600 }); // NOSONAR — see S8707 note above
5807
5895
 
5808
5896
  // handler.py — substitute config values; use JSON.stringify for safe string embedding
5809
5897
  const safeKeyPath = JSON.stringify(userConfig.hermes.sshKeyPath);
@@ -5940,7 +6028,7 @@ def _strip_markdown(text: str) -> str:
5940
6028
  text = re.sub(r"\\s+", " ", text).strip()
5941
6029
  return text
5942
6030
  `;
5943
- await fs.writeFile(path.join(hermesHooksDir, 'handler.py'), handlerPy, { mode: 0o600 });
6031
+ await fs.writeFile(path.join(hermesHooksDir, 'handler.py'), handlerPy, { mode: 0o600 }); // NOSONAR — see S8707 note above
5944
6032
  spinner.succeed(chalk.green('Hermes integration installed!'));
5945
6033
  console.log(chalk.gray(` Hooks written to: ${hermesHooksDir}`));
5946
6034
  console.log(chalk.gray(' Run: hermes gateway restart'));
@@ -6721,4 +6809,9 @@ export {
6721
6809
  // Manifest utilities (used by tests and external tooling)
6722
6810
  getProjectManifestPath, getGlobalManifestPath,
6723
6811
  loadManifest, saveManifest, computeFileHash, manifestSafeCopy, removeManifestFiles,
6812
+ // Pure helper functions exported for testing
6813
+ isPiperProvider, supportsEmoji, getPersonalityIcon,
6814
+ detectEnvironment, createPageHeaderFooter, buildNavigationChoices, handleNavigationAction, getPageTitle,
6815
+ getUserShell, showWelcome, getReleaseInfoBoxen, generateActivationInstructions,
6816
+ collectConfiguration,
6724
6817
  };