agentvibes 3.5.9 → 3.5.10-alpha.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.
@@ -1 +1 @@
1
- 20260212
1
+ 20260214
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Publish](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml/badge.svg)](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml)
12
12
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
13
13
 
14
- **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v3.5.9
14
+ **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v3.5.10-alpha.0
15
15
 
16
16
  ---
17
17
 
@@ -97,7 +97,7 @@ All 50+ Piper voices AgentVibes provides are sourced from Hugging Face's open-so
97
97
  - [📱 Android/Termux](#-quick-setup-android--termux-claude-code-on-your-phone) - Run Claude Code on your phone
98
98
  - [📋 Prerequisites](#-prerequisites) - What you actually need (Node.js + optional tools)
99
99
  - [✨ What is AgentVibes?](#-what-is-agentvibes) - Overview & key features
100
- - [📰 Latest Release](#-latest-release) - v3.5.9 Security & UX + v3.5.5 Native Windows Support
100
+ - [📰 Latest Release](#-latest-release) - v3.5.10-alpha.0 (Alpha) with Soprano Detection Fixes + v3.5.5 Native Windows Support
101
101
  - [🪟 Windows Setup Guide for Claude Desktop](mcp-server/WINDOWS_SETUP.md) - Complete Windows installation with WSL & Python
102
102
 
103
103
  ### AgentVibes MCP (Natural Language Control)
@@ -142,9 +142,16 @@ All 50+ Piper voices AgentVibes provides are sourced from Hugging Face's open-so
142
142
 
143
143
  ## 📰 Latest Release
144
144
 
145
- **[v3.5.9 - Security & Provider Validation](https://github.com/paulpreibisch/AgentVibes/releases/tag/v3.5.9)** 🛡️
145
+ **[v3.5.10-alpha.0 - Soprano Detection Fixes & Features](https://github.com/paulpreibisch/AgentVibes/releases/tag/master)** (Alpha) 🛡️
146
146
 
147
- Critical security update: Fixed command injection vulnerabilities, HOME directory injection prevention, and path traversal protection. Soprano TTS installed via pipx now correctly detected. Enhanced provider detection messaging and debug logging.
147
+ Critical fixes and new features:
148
+ - Fixed Soprano TTS detection when installed via pipx
149
+ - Fixed command injection vulnerabilities in provider validation
150
+ - Eliminated code duplication between Soprano and Piper validators
151
+ - Added custom music tracks support with preview functionality
152
+ - Added personality emoji mapping for better visual recognition
153
+ - Added pretext configuration for custom agent introductions
154
+ - HOME directory injection prevention and path traversal protection maintained
148
155
 
149
156
  **Foundation Release:** [v3.5.5 - Native Windows Support](https://github.com/paulpreibisch/AgentVibes/releases/tag/v3.5.9) brings Windows support (Soprano, Piper, SAPI), background music (16 genre tracks), reverb/audio effects, and verbosity control. [See release notes](RELEASE_NOTES.md) for complete v3.5.5-3.5.9 history.
150
157
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "agentvibes",
4
- "version": "3.5.9",
4
+ "version": "3.5.10-alpha.0",
5
5
  "description": "Now your AI Agents can finally talk back! Professional TTS voice for Claude Code, Claude Desktop (via MCP), and Clawdbot with multi-provider support.",
6
6
  "homepage": "https://agentvibes.org",
7
7
  "keywords": [
@@ -83,6 +83,8 @@
83
83
  "test:verbose": "AGENTVIBES_TEST_MODE=true bats -t test/unit/*.bats"
84
84
  },
85
85
  "dependencies": {
86
+ "@inquirer/search": "^3.1.3",
87
+ "agentvibes": "^3.5.9",
86
88
  "boxen": "^7.0.0",
87
89
  "chalk": "^5.0.0",
88
90
  "commander": "^10.0.0",
package/src/installer.js CHANGED
@@ -48,6 +48,7 @@ import fsSync from 'node:fs';
48
48
  import { execSync, execFileSync, spawn } from 'node:child_process';
49
49
  import chalk from 'chalk';
50
50
  import inquirer from 'inquirer';
51
+ import search from '@inquirer/search';
51
52
  import figlet from 'figlet';
52
53
  import { detectBMAD } from './bmad-detector.js';
53
54
  import boxen from 'boxen';
@@ -77,6 +78,31 @@ const packageJson = JSON.parse(
77
78
  );
78
79
  const VERSION = packageJson.version;
79
80
 
81
+ // Personality emoji mapping for quick visual recognition
82
+ const personalityEmojis = {
83
+ 'angry': '😠',
84
+ 'annoying': '😤',
85
+ 'crass': '🤬',
86
+ 'dramatic': '🎭',
87
+ 'dry-humor': '😐',
88
+ 'flirty': '😘',
89
+ 'funny': '😂',
90
+ 'grandpa': '👴',
91
+ 'millennial': '🙄',
92
+ 'moody': '😒',
93
+ 'none': '😊',
94
+ 'normal': '😊',
95
+ 'pirate': '🏴‍☠️',
96
+ 'poetic': '📜',
97
+ 'professional': '👔',
98
+ 'rapper': '🎤',
99
+ 'robot': '🤖',
100
+ 'sarcastic': '😏',
101
+ 'sassy': '💁',
102
+ 'surfer-dude': '🏄',
103
+ 'zen': '🧘'
104
+ };
105
+
80
106
  // Validate Node.js executable is available (CLAUDE.md - early validation)
81
107
  if (!process.execPath) {
82
108
  console.error('❌ Error: Node.js executable path not found');
@@ -519,6 +545,159 @@ async function handleSystemDependenciesPage() {
519
545
  console.log(depsBoxen);
520
546
  }
521
547
 
548
+ /**
549
+ * Validate and copy custom music track to .claude/audio/tracks directory
550
+ * @param {string} userFilePath - Path provided by user
551
+ * @param {string} tracksDir - Target directory for audio tracks
552
+ * @returns {Promise<string|null>} Filename if successful, null if cancelled
553
+ */
554
+ async function handleCustomMusicTrack(userFilePath, tracksDir) {
555
+ try {
556
+ // Validate file exists and resolve path securely
557
+ const resolvedPath = path.resolve(userFilePath.trim());
558
+
559
+ if (!fsSync.existsSync(resolvedPath)) {
560
+ console.error(chalk.red('✗ File not found. Please check the path.'));
561
+ return null;
562
+ }
563
+
564
+ // Validate file extension (whitelist approach per CLAUDE.md)
565
+ const ext = path.extname(resolvedPath).toLowerCase();
566
+ const supportedFormats = ['.mp3', '.wav', '.ogg', '.m4a'];
567
+ if (!supportedFormats.includes(ext)) {
568
+ console.error(chalk.red('✗ Unsupported format. Use: .mp3, .wav, .ogg, or .m4a'));
569
+ return null;
570
+ }
571
+
572
+ // Verify file is within expected directory (prevent path traversal)
573
+ if (!resolvedPath.startsWith(path.resolve(process.env.HOME || process.env.USERPROFILE))) {
574
+ console.error(chalk.red('✗ File must be in your home directory or subdirectories.'));
575
+ return null;
576
+ }
577
+
578
+ // Get original filename and sanitize it
579
+ let originalFilename = path.basename(resolvedPath);
580
+ const sanitizedFilename = originalFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
581
+
582
+ // Create tracks directory if needed
583
+ await fs.mkdir(tracksDir, { recursive: true });
584
+
585
+ // Copy file to tracks directory
586
+ const destPath = path.join(tracksDir, sanitizedFilename);
587
+ await fs.copyFile(resolvedPath, destPath);
588
+
589
+ return sanitizedFilename;
590
+ } catch (err) {
591
+ console.error(chalk.red(`✗ Error: ${err.message}`));
592
+ return null;
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Load custom tracks from global registry
598
+ * @returns {Promise<Array>} Array of custom track objects {name, filename}
599
+ */
600
+ async function loadCustomTracks() {
601
+ try {
602
+ const registryPath = path.join(process.env.HOME || process.env.USERPROFILE, '.agentvibes', 'custom-tracks.json');
603
+ if (fsSync.existsSync(registryPath)) {
604
+ const content = await fs.readFile(registryPath, 'utf-8');
605
+ return JSON.parse(content);
606
+ }
607
+ } catch (err) {
608
+ // Silently fail - registry may not exist yet
609
+ }
610
+ return [];
611
+ }
612
+
613
+ /**
614
+ * Save custom tracks to global registry
615
+ * @param {Array} tracks - Array of custom track objects
616
+ */
617
+ async function saveCustomTracks(tracks) {
618
+ try {
619
+ const registryDir = path.join(process.env.HOME || process.env.USERPROFILE, '.agentvibes');
620
+ await fs.mkdir(registryDir, { recursive: true });
621
+ const registryPath = path.join(registryDir, 'custom-tracks.json');
622
+ await fs.writeFile(registryPath, JSON.stringify(tracks, null, 2));
623
+ } catch (err) {
624
+ // Silently fail - non-critical
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Preview audio track using available audio player
630
+ * @param {string} trackName - Name of the track file to preview
631
+ * @param {string} tracksDir - Directory containing audio tracks
632
+ * @returns {Promise<boolean>} True if preview was attempted, false if no audio tools available
633
+ */
634
+ async function previewAudioTrack(trackName, tracksDir) {
635
+ const trackPath = path.join(tracksDir, trackName);
636
+
637
+ // Verify track exists
638
+ if (!fsSync.existsSync(trackPath)) {
639
+ console.log(chalk.yellow('⚠️ Track file not found'));
640
+ return false;
641
+ }
642
+
643
+ // Try available audio players in order of preference
644
+ const audioPlayers = ['ffplay', 'play', 'mpv'];
645
+ let playerAvailable = false;
646
+
647
+ for (const player of audioPlayers) {
648
+ try {
649
+ execSync(`which ${player}`, { stdio: 'ignore' });
650
+ playerAvailable = true;
651
+
652
+ console.log(chalk.cyan('▶ Playing preview (10 seconds)...'));
653
+
654
+ // Build appropriate command for each player
655
+ let playerArgs = [];
656
+ if (player === 'ffplay') {
657
+ playerArgs = ['-nodisp', '-autoexit', '-t', '10', '-volume', '30', trackPath];
658
+ } else if (player === 'play') {
659
+ playerArgs = [trackPath, 'trim', '0', '10'];
660
+ } else if (player === 'mpv') {
661
+ playerArgs = ['--no-video', '--duration=10', '--volume=30', trackPath];
662
+ }
663
+
664
+ // Spawn player process with safety timeout
665
+ const audioProcess = spawn(player, playerArgs, {
666
+ stdio: ['ignore', 'ignore', 'ignore'],
667
+ timeout: 12000 // 12 second timeout for safety
668
+ });
669
+
670
+ // Handle process completion
671
+ return new Promise((resolve) => {
672
+ const timeoutHandle = setTimeout(() => {
673
+ if (audioProcess && !audioProcess.killed) {
674
+ audioProcess.kill('SIGTERM');
675
+ }
676
+ resolve(true);
677
+ }, 11000); // 11 second timeout
678
+
679
+ audioProcess.on('close', () => {
680
+ clearTimeout(timeoutHandle);
681
+ resolve(true);
682
+ });
683
+
684
+ audioProcess.on('error', () => {
685
+ clearTimeout(timeoutHandle);
686
+ resolve(true);
687
+ });
688
+ });
689
+ } catch (err) {
690
+ // This player not available, try next
691
+ continue;
692
+ }
693
+ }
694
+
695
+ if (!playerAvailable) {
696
+ console.log(chalk.yellow('⚠️ Audio preview requires ffplay, sox (play), or mpv'));
697
+ }
698
+ return false;
699
+ }
700
+
522
701
  /**
523
702
  * Collect all configuration answers through paginated question flow
524
703
  * @param {Object} options - Installation options (yes, pageOffset, totalPages)
@@ -530,6 +709,7 @@ async function collectConfiguration(options = {}) {
530
709
  piperPath: null,
531
710
  sshHost: null,
532
711
  defaultVoice: null,
712
+ pretext: '',
533
713
  personality: 'none',
534
714
  reverb: 'light',
535
715
  backgroundMusic: {
@@ -1314,7 +1494,37 @@ async function collectConfiguration(options = {}) {
1314
1494
  }
1315
1495
 
1316
1496
  } else if (currentPage === 3) {
1317
- // Page 4: Personality Selection
1497
+ // Page 4: Pretext and Personality Selection
1498
+ console.log(boxen(
1499
+ chalk.white('Customize your Agent\'s introduction!\n\n') +
1500
+ chalk.gray('Add optional intro text that prefixes all TTS messages.\n') +
1501
+ chalk.gray('Examples: "FireBot: ", "Agent: ", "🤖 Assistant: "\n\n') +
1502
+ chalk.gray('You can change this anytime with: ') + chalk.cyan('/agent-vibes:set-pretext <text>'),
1503
+ {
1504
+ padding: 1,
1505
+ margin: { top: 0, bottom: 0, left: 0, right: 0 },
1506
+ borderStyle: 'round',
1507
+ borderColor: 'gray',
1508
+ width: 80
1509
+ }
1510
+ ));
1511
+
1512
+ // Pretext input
1513
+ const { pretext } = await inquirer.prompt([{
1514
+ type: 'input',
1515
+ name: 'pretext',
1516
+ message: chalk.yellow('Enter intro text (optional, press Enter to skip):'),
1517
+ default: config.pretext || ''
1518
+ }]);
1519
+
1520
+ config.pretext = pretext.trim();
1521
+ if (config.pretext) {
1522
+ console.log(chalk.green(`✓ Intro text set: "${config.pretext}"\n`));
1523
+ } else {
1524
+ console.log(chalk.gray('→ No intro text (messages will speak normally)\n'));
1525
+ }
1526
+
1527
+ // Page 5: Personality Selection
1318
1528
  console.log(boxen(
1319
1529
  chalk.white('Give your Agent a personality!\n\n') +
1320
1530
  chalk.gray('Personalities add character and style to TTS responses.\n') +
@@ -1359,16 +1569,18 @@ async function collectConfiguration(options = {}) {
1359
1569
  personalities.sort((a, b) => a.name.localeCompare(b.name));
1360
1570
 
1361
1571
  // Add "none" as first option (default)
1572
+ const noneEmoji = personalityEmojis['none'] || '✨';
1362
1573
  personalityChoices.push(
1363
- { name: chalk.green('none') + chalk.gray(' (Professional, no personality) - Recommended'), value: 'none' },
1574
+ { name: noneEmoji + ' ' + chalk.green('none') + chalk.gray(' (Professional, no personality) - Recommended'), value: 'none' },
1364
1575
  new inquirer.Separator(chalk.gray('─'.repeat(60)))
1365
1576
  );
1366
1577
 
1367
1578
  // Add all other personalities
1368
1579
  for (const p of personalities) {
1369
1580
  if (p.name !== 'normal') { // Skip 'normal' as it's similar to 'none'
1581
+ const emoji = personalityEmojis[p.name] || '✨';
1370
1582
  personalityChoices.push({
1371
- name: chalk.cyan(p.name) + chalk.gray(` - ${p.description}`),
1583
+ name: emoji + ' ' + chalk.cyan(p.name) + chalk.gray(` - ${p.description}`),
1372
1584
  value: p.name
1373
1585
  });
1374
1586
  }
@@ -1387,14 +1599,23 @@ async function collectConfiguration(options = {}) {
1387
1599
  ];
1388
1600
  }
1389
1601
 
1390
- const { selectedPersonality } = await inquirer.prompt([{
1391
- type: 'list',
1392
- name: 'selectedPersonality',
1393
- message: chalk.yellow('Select your default personality:'),
1394
- choices: personalityChoices,
1395
- default: 'none',
1602
+ // Use search prompt for keyboard navigation (type to filter)
1603
+ const selectedPersonality = await search({
1604
+ message: chalk.yellow('Select your default personality (type to search):'),
1605
+ source: async (input) => {
1606
+ // Filter personalityChoices based on input
1607
+ if (!input) {
1608
+ return personalityChoices;
1609
+ }
1610
+ return personalityChoices.filter(choice => {
1611
+ // Check if choice is a Separator, if so skip it
1612
+ if (!choice.value) return false;
1613
+ return choice.value.toLowerCase().includes(input.toLowerCase()) ||
1614
+ (choice.name && choice.name.toLowerCase().includes(input.toLowerCase()));
1615
+ });
1616
+ },
1396
1617
  pageSize: 15
1397
- }]);
1618
+ });
1398
1619
 
1399
1620
  if (selectedPersonality === '__back__') {
1400
1621
  currentPage--; // Go back to voice selection
@@ -1514,6 +1735,13 @@ async function collectConfiguration(options = {}) {
1514
1735
  console.log('');
1515
1736
  console.log(chalk.gray('🎼 Choose your default background music genre (you can change this anytime).'));
1516
1737
 
1738
+ // Load custom tracks from registry
1739
+ const customTracks = await loadCustomTracks();
1740
+ const customTrackChoices = customTracks.map(track => ({
1741
+ name: `📁 ${track.name}`,
1742
+ value: track.filename
1743
+ }));
1744
+
1517
1745
  const trackChoices = [
1518
1746
  { name: '🎻 Soft Flamenco (Spanish guitar)', value: 'agentvibes_soft_flamenco_loop.mp3' },
1519
1747
  { name: '🎺 Bachata (Latin - Romantic guitar & bongos)', value: 'agent_vibes_bachata_v1_loop.mp3' },
@@ -1533,20 +1761,85 @@ async function collectConfiguration(options = {}) {
1533
1761
  { name: '🥁 Tabla Dream Pop (Indian percussion)', value: 'agent_vibes_tabla_dream_pop_v1_loop.mp3' }
1534
1762
  ];
1535
1763
 
1764
+ // Add custom tracks separator and options if any exist
1765
+ if (customTrackChoices.length > 0) {
1766
+ trackChoices.push(
1767
+ new inquirer.Separator(chalk.gray('─'.repeat(50))),
1768
+ ...customTrackChoices
1769
+ );
1770
+ }
1771
+
1772
+ // Add custom track option
1773
+ trackChoices.push(
1774
+ new inquirer.Separator(chalk.gray('─'.repeat(50))),
1775
+ { name: '➕ Add Custom Track...', value: '__custom__' }
1776
+ );
1777
+
1536
1778
  const { selectedTrack } = await inquirer.prompt([{
1537
1779
  type: 'list',
1538
1780
  name: 'selectedTrack',
1539
1781
  message: chalk.yellow('Choose default background music track:'),
1540
1782
  choices: trackChoices,
1541
1783
  default: config.backgroundMusic.track || 'agentvibes_soft_flamenco_loop.mp3',
1542
- pageSize: 16
1784
+ pageSize: 18
1543
1785
  }]);
1544
1786
 
1545
- config.backgroundMusic.track = selectedTrack;
1787
+ // Handle custom track selection
1788
+ if (selectedTrack === '__custom__') {
1789
+ const tracksDir = path.join(__dirname, '..', '.claude', 'audio', 'tracks');
1790
+ const { customTrackPath } = await inquirer.prompt([{
1791
+ type: 'input',
1792
+ name: 'customTrackPath',
1793
+ message: chalk.yellow('Enter the full path to your audio file:'),
1794
+ validate: (input) => {
1795
+ const resolvedPath = path.resolve(input.trim());
1796
+ if (!fsSync.existsSync(resolvedPath)) return 'File not found';
1797
+ const ext = path.extname(resolvedPath).toLowerCase();
1798
+ if (!['.mp3', '.wav', '.ogg', '.m4a'].includes(ext))
1799
+ return 'Unsupported format (use .mp3, .wav, .ogg, or .m4a)';
1800
+ return true;
1801
+ }
1802
+ }]);
1803
+
1804
+ const copiedFilename = await handleCustomMusicTrack(customTrackPath, tracksDir);
1805
+ if (copiedFilename) {
1806
+ config.backgroundMusic.track = copiedFilename;
1807
+ console.log(chalk.green(`✓ Custom track added: ${copiedFilename}`));
1808
+
1809
+ // Update registry with new custom track
1810
+ const trackName = path.basename(customTrackPath, path.extname(customTrackPath));
1811
+ const allCustomTracks = await loadCustomTracks();
1812
+ if (!allCustomTracks.some(t => t.filename === copiedFilename)) {
1813
+ allCustomTracks.push({ name: trackName, filename: copiedFilename });
1814
+ await saveCustomTracks(allCustomTracks);
1815
+ }
1816
+ } else {
1817
+ // Fall back to default if custom track fails
1818
+ config.backgroundMusic.track = 'agentvibes_soft_flamenco_loop.mp3';
1819
+ console.log(chalk.yellow('⚠️ Using default track'));
1820
+ }
1821
+ } else {
1822
+ config.backgroundMusic.track = selectedTrack;
1823
+ }
1824
+
1825
+ // Offer preview of selected track
1826
+ const tracksDir = path.join(__dirname, '..', '.claude', 'audio', 'tracks');
1827
+ const { previewTrack } = await inquirer.prompt([{
1828
+ type: 'confirm',
1829
+ name: 'previewTrack',
1830
+ message: chalk.cyan('Preview this track before continuing?'),
1831
+ default: false
1832
+ }]);
1833
+
1834
+ if (previewTrack) {
1835
+ console.log('');
1836
+ await previewAudioTrack(config.backgroundMusic.track, tracksDir);
1837
+ console.log('');
1838
+ }
1546
1839
  }
1547
1840
 
1548
1841
  // Auto-advance to next page after audio settings
1549
- console.log(chalk.green('\n✓ Audio settings configured\n'));
1842
+ console.log(chalk.green('✓ Audio settings configured\n'));
1550
1843
  currentPage++;
1551
1844
  continue;
1552
1845
 
@@ -2509,30 +2802,6 @@ async function copyPersonalityFiles(targetDir, spinner) {
2509
2802
  content += chalk.gray('Personalities change how Claude speaks - adding humor, emotion, or style.\n');
2510
2803
  content += chalk.gray('Change with: ') + chalk.yellow('/agent-vibes:personality <name>') + chalk.gray(' or say "change personality to sassy"\n\n');
2511
2804
 
2512
- // Map personalities to emojis
2513
- const personalityEmojis = {
2514
- 'angry': '😠',
2515
- 'annoying': '😤',
2516
- 'crass': '🤬',
2517
- 'dramatic': '🎭',
2518
- 'dry-humor': '😐',
2519
- 'flirty': '😘',
2520
- 'funny': '😂',
2521
- 'grandpa': '👴',
2522
- 'millennial': '🙄',
2523
- 'moody': '😒',
2524
- 'normal': '😊',
2525
- 'pirate': '🏴‍☠️',
2526
- 'poetic': '📜',
2527
- 'professional': '👔',
2528
- 'rapper': '🎤',
2529
- 'robot': '🤖',
2530
- 'sarcastic': '😏',
2531
- 'sassy': '💁',
2532
- 'surfer-dude': '🏄',
2533
- 'zen': '🧘'
2534
- };
2535
-
2536
2805
  // Display personalities in two columns
2537
2806
  const personalities = installedPersonalities.map(file => {
2538
2807
  const name = file.replace('.md', '');
@@ -4387,6 +4656,14 @@ Troubleshooting:
4387
4656
  await fs.writeFile(personalityFile, userConfig.personality);
4388
4657
  }
4389
4658
 
4659
+ // Apply pretext configuration from userConfig
4660
+ if (userConfig.pretext && userConfig.pretext.trim()) {
4661
+ const pretextFile = path.join(claudeDir, 'config', 'tts-pretext.txt');
4662
+ const configDir = path.join(claudeDir, 'config');
4663
+ await fs.mkdir(configDir, { recursive: true });
4664
+ await fs.writeFile(pretextFile, userConfig.pretext.trim());
4665
+ }
4666
+
4390
4667
  // Initialize piperVoicesBoxen outside the conditional for proper scoping
4391
4668
  let piperVoicesBoxen = null;
4392
4669
 
@@ -9,10 +9,50 @@ import path from 'node:path'; // For safe path operations and traversal preventi
9
9
  import fs from 'node:fs'; // For checking file/directory existence
10
10
  import os from 'node:os'; // For os.homedir() to prevent HOME injection attacks
11
11
 
12
+ /**
13
+ * Helper: Check if command exists in PATH
14
+ * @param {string} command - Command name to check
15
+ * @returns {boolean} True if command exists in PATH
16
+ */
17
+ function commandExistsInPath(command) {
18
+ try {
19
+ execSync(`which "${command}" 2>/dev/null`, {
20
+ encoding: 'utf8',
21
+ shell: true,
22
+ stdio: ['pipe', 'pipe', 'pipe']
23
+ });
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Helper: Check if file/directory exists and is within home directory
32
+ * @param {string} targetPath - Full path to check
33
+ * @returns {boolean} True if path exists and is safe
34
+ */
35
+ function isSafePathExists(targetPath) {
36
+ try {
37
+ const homeDir = os.homedir();
38
+ const resolvedPath = path.resolve(targetPath);
39
+ const resolvedHome = path.resolve(homeDir);
40
+
41
+ // Validate path is within home directory (prevent path traversal)
42
+ if (!resolvedPath.startsWith(resolvedHome)) {
43
+ return false;
44
+ }
45
+
46
+ return fs.existsSync(targetPath);
47
+ } catch (error) {
48
+ return false;
49
+ }
50
+ }
51
+
12
52
  /**
13
53
  * Validate a TTS provider's installation status
14
54
  * @param {string} providerName - Provider name (soprano, piper, macos, windows-sapi, etc.)
15
- * @returns {Promise<{installed: boolean, message: string, pythonVersion?: string, error?: string}>}
55
+ * @returns {Promise<{installed: boolean, message: string, checkedLocations?: string[], error?: string}>}
16
56
  */
17
57
  export async function validateProvider(providerName) {
18
58
  switch (providerName) {
@@ -39,57 +79,54 @@ export async function validateProvider(providerName) {
39
79
  }
40
80
 
41
81
  /**
42
- * Validate Soprano TTS installation
43
- * Checks multiple Python versions since Soprano might be in non-default Python
44
- * @returns {Promise<{installed: boolean, message: string, pythonVersion?: string, checkedCount?: number}>}
82
+ * Helper: Check if pipx provider is installed (Soprano, Piper)
83
+ * @param {string} providerName - Provider name (soprano or piper)
84
+ * @param {string} packageName - Package name (soprano-tts or piper-tts)
85
+ * @returns {Promise<{installed: boolean, message: string, checkedLocations: string[]}>}
45
86
  */
46
- export async function validateSopranoInstallation() {
87
+ async function validatePipxProvider(providerName, packageName) {
47
88
  const checkedLocations = [];
89
+ const binName = providerName === 'soprano' ? 'soprano' : 'piper';
90
+ const venvName = providerName === 'soprano' ? 'soprano-tts' : 'piper-tts';
48
91
 
49
- // Check for pipx installation first (common for CLI tools)
50
- // Use os.homedir() (not env var) to prevent HOME injection attacks
51
- try {
52
- const homeDir = os.homedir();
53
- const sopranoVenvPath = path.join(homeDir, '.local', 'share', 'pipx', 'venvs', 'soprano-tts');
92
+ // Check for binary in PATH first (most reliable for pipx installations)
93
+ if (commandExistsInPath(binName)) {
94
+ return { installed: true, message: `${providerName} TTS detected (binary in PATH)`, checkedLocations: ['PATH'] };
95
+ }
96
+ checkedLocations.push('PATH');
54
97
 
55
- // Validate path is within home directory (prevent path traversal)
56
- const resolvedPath = path.resolve(sopranoVenvPath);
57
- const resolvedHome = path.resolve(homeDir);
58
- if (!resolvedPath.startsWith(resolvedHome)) {
59
- throw new Error('Path traversal detected');
60
- }
98
+ // Check for pipx bin directory directly
99
+ const homeDir = os.homedir();
100
+ const binPath = path.join(homeDir, '.local', 'bin', binName);
101
+ if (isSafePathExists(binPath)) {
102
+ return { installed: true, message: `${providerName} TTS detected (via pipx bin)`, checkedLocations: ['~/.local/bin'] };
103
+ }
104
+ checkedLocations.push('~/.local/bin');
61
105
 
62
- if (fs.existsSync(sopranoVenvPath)) {
63
- checkedLocations.push('pipx'); // Always track what was checked
64
- return { installed: true, message: 'Soprano TTS detected (via pipx)', checkedLocations };
65
- }
66
- } catch (error) {
67
- // If home directory check fails, fall through to Python checks
68
- if (error.code !== 'ENOENT') {
69
- console.error('[DEBUG] Pipx check error:', error.message);
70
- }
106
+ // Check for pipx venv installation directory
107
+ const venvPath = path.join(homeDir, '.local', 'share', 'pipx', 'venvs', venvName);
108
+ if (isSafePathExists(venvPath)) {
109
+ return { installed: true, message: `${providerName} TTS detected (via pipx venv)`, checkedLocations: ['pipx venv'] };
71
110
  }
72
- checkedLocations.push('pipx');
111
+ checkedLocations.push('pipx venv');
73
112
 
74
- // Comprehensive Python version detection
113
+ // Check Python package installations (comprehensive version detection)
75
114
  const pythonCommands = ['python3', 'python', 'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3.8'];
76
115
 
77
116
  for (const pythonCmd of pythonCommands) {
78
117
  try {
79
- // Use array form to prevent command injection (security: CLAUDE.md)
80
- const result = execSync(pythonCmd, ['-m', 'pip', 'show', 'soprano-tts'], {
118
+ // Use spawnSync with array args (security: correct API usage per CLAUDE.md)
119
+ const result = spawnSync(pythonCmd, ['-m', 'pip', 'show', packageName], {
81
120
  encoding: 'utf8',
82
121
  stdio: ['pipe', 'pipe', 'pipe'],
83
122
  timeout: 10000
84
123
  });
85
124
 
86
- if (result && result.trim()) {
87
- checkedLocations.push(pythonCmd); // Track which Python found it
125
+ if (result.status === 0 && result.stdout && result.stdout.trim()) {
88
126
  return {
89
127
  installed: true,
90
- message: `Soprano TTS detected via ${pythonCmd}`,
91
- pythonVersion: pythonCmd,
92
- checkedCount: checkedLocations.length + pythonCommands.indexOf(pythonCmd)
128
+ message: `${providerName} TTS detected (Python package via ${pythonCmd})`,
129
+ checkedLocations: [...checkedLocations, pythonCmd]
93
130
  };
94
131
  }
95
132
  } catch (error) {
@@ -97,81 +134,32 @@ export async function validateSopranoInstallation() {
97
134
  }
98
135
  }
99
136
 
100
- // Build list of Python versions checked
101
137
  checkedLocations.push(...pythonCommands);
102
138
 
103
139
  return {
104
140
  installed: false,
105
- message: `Soprano TTS is not installed on your system (checked: ${checkedLocations.join(', ')})`,
106
- error: 'SOPRANO_NOT_FOUND',
107
- checkedCount: checkedLocations.length
141
+ message: `${providerName} TTS is not installed on your system (checked: ${checkedLocations.join(', ')})`,
142
+ error: `${providerName.toUpperCase()}_NOT_FOUND`,
143
+ checkedLocations
108
144
  };
109
145
  }
110
146
 
147
+ /**
148
+ * Validate Soprano TTS installation
149
+ * Checks multiple locations: PATH, pipx bin, pipx venv, Python packages
150
+ * @returns {Promise<{installed: boolean, message: string, checkedLocations?: string[], error?: string}>}
151
+ */
152
+ export async function validateSopranoInstallation() {
153
+ return await validatePipxProvider('soprano', 'soprano-tts');
154
+ }
155
+
111
156
  /**
112
157
  * Validate Piper TTS installation
113
- * Checks if Piper is available via pipx or direct installation
114
- * Suppresses all error output for clean UX
115
- * @returns {Promise<{installed: boolean, message: string}>}
158
+ * Checks multiple locations: PATH, pipx bin, pipx venv, Python packages
159
+ * @returns {Promise<{installed: boolean, message: string, checkedLocations?: string[], error?: string}>}
116
160
  */
117
161
  export async function validatePiperInstallation() {
118
- const checkedLocations = [];
119
-
120
- // Check for piper binary with error suppression
121
- try {
122
- execSync('which piper 2>/dev/null', {
123
- encoding: 'utf8',
124
- shell: true,
125
- stdio: ['pipe', 'pipe', 'pipe']
126
- });
127
- return { installed: true, message: 'Piper TTS detected (binary in PATH)' };
128
- } catch (error) {
129
- checkedLocations.push('PATH (piper binary)');
130
- }
131
-
132
- // Check for pipx installation (use venv directory check - more reliable than pipx list)
133
- try {
134
- const homeDir = os.homedir(); // Use os.homedir() not env var (security: prevent HOME injection)
135
- const piperVenvPath = path.join(homeDir, '.local', 'share', 'pipx', 'venvs', 'piper-tts');
136
-
137
- // Validate path is within home directory (prevent path traversal)
138
- const resolvedPath = path.resolve(piperVenvPath);
139
- const resolvedHome = path.resolve(homeDir);
140
- if (!resolvedPath.startsWith(resolvedHome)) {
141
- throw new Error('Path traversal detected');
142
- }
143
-
144
- if (fs.existsSync(piperVenvPath)) {
145
- checkedLocations.push('pipx'); // Always track
146
- return { installed: true, message: 'Piper TTS detected (via pipx)', checkedLocations };
147
- }
148
- } catch (error) {
149
- if (error.code !== 'ENOENT') {
150
- console.error('[DEBUG] Pipx check error:', error.message);
151
- }
152
- }
153
- checkedLocations.push('pipx');
154
-
155
- // Check if Python + piper-tts package installed using array form (security: prevent injection)
156
- try {
157
- const result = execSync('python3', ['-m', 'pip', 'show', 'piper-tts'], {
158
- encoding: 'utf8',
159
- stdio: ['pipe', 'pipe', 'pipe'],
160
- timeout: 10000
161
- });
162
- if (result && result.trim()) {
163
- checkedLocations.push('python3 pip'); // Track what found it
164
- return { installed: true, message: 'Piper TTS detected (Python package)', checkedLocations };
165
- }
166
- } catch (error) {
167
- checkedLocations.push('python3 pip');
168
- }
169
-
170
- return {
171
- installed: false,
172
- message: `Piper TTS is not installed on your system (checked: ${checkedLocations.join(', ')})`,
173
- error: 'PIPER_NOT_FOUND'
174
- };
162
+ return await validatePipxProvider('piper', 'piper-tts');
175
163
  }
176
164
 
177
165
  /**
@@ -273,11 +261,17 @@ async function testSopranoRuntime() {
273
261
  */
274
262
  async function testPiperRuntime() {
275
263
  try {
276
- execSync('piper', ['--help'], {
264
+ const result = spawnSync('piper', ['--help'], {
277
265
  encoding: 'utf8',
278
266
  stdio: ['pipe', 'pipe', 'pipe'],
279
267
  timeout: 5000
280
268
  });
269
+ if (result.error || result.status !== 0) {
270
+ return {
271
+ working: false,
272
+ error: 'Piper command execution failed'
273
+ };
274
+ }
281
275
  return { working: true };
282
276
  } catch (e) {
283
277
  return {
@@ -292,11 +286,17 @@ async function testPiperRuntime() {
292
286
  */
293
287
  async function testMacOSRuntime() {
294
288
  try {
295
- execSync('say', ['-f', '/dev/null'], {
289
+ const result = spawnSync('say', ['-f', '/dev/null'], {
296
290
  encoding: 'utf8',
297
291
  timeout: 5000,
298
292
  stdio: ['pipe', 'pipe', 'pipe']
299
293
  });
294
+ if (result.error || result.status !== 0) {
295
+ return {
296
+ working: false,
297
+ error: 'macOS Say command execution failed'
298
+ };
299
+ }
300
300
  return { working: true };
301
301
  } catch (e) {
302
302
  return {
@@ -339,52 +339,59 @@ export async function attemptProviderInstallation(providerName) {
339
339
  return { success: false, message: `Unknown provider: ${providerName}` };
340
340
  }
341
341
 
342
- // Strategy 1: Try regular pip install (using array form for security - CRITICAL #1 fix)
342
+ // Strategy 1: Try regular pip install (using spawnSync for correct API usage)
343
343
  try {
344
344
  // Show installation in progress
345
345
  console.log(` Attempting: pip install ${pkgName}...`);
346
- execSync('pip', ['install', pkgName], {
346
+ const result = spawnSync('pip', ['install', pkgName], {
347
347
  stdio: 'inherit',
348
- timeout: 60000 // HIGH #1 fix - 60 second timeout
348
+ timeout: 60000
349
349
  });
350
350
 
351
- // Verify installation actually worked (proves it's installed)
352
- const validation = await validateProvider(providerName);
353
- if (validation.installed) {
354
- return {
355
- success: true,
356
- message: `Successfully installed via pip`,
357
- command: `pip install ${pkgName}`,
358
- verified: true
359
- };
360
- }
351
+ if (result.error || result.status !== 0) {
352
+ // Strategy 1 failed - continue to Strategy 2
353
+ } else {
354
+ // Verify installation actually worked (proves it's installed)
355
+ const validation = await validateProvider(providerName);
356
+ if (validation.installed) {
357
+ return {
358
+ success: true,
359
+ message: `Successfully installed via pip`,
360
+ command: `pip install ${pkgName}`,
361
+ verified: true
362
+ };
363
+ }
361
364
 
362
- return { success: true, message: `Successfully installed via pip`, command: `pip install ${pkgName}` };
365
+ return { success: true, message: `Successfully installed via pip`, command: `pip install ${pkgName}` };
366
+ }
363
367
  } catch (error) {
364
368
  // Strategy 1 failed - continue to Strategy 2
365
- // Don't check specific error messages - just try next strategy (MEDIUM #3 fix)
366
369
  }
367
370
 
368
371
  // Strategy 2: Try pipx install (recommended for PEP 668 protection)
369
372
  try {
370
373
  console.log(` Attempting: pipx install ${pkgName}...`);
371
- execSync('pipx', ['install', pkgName], {
374
+ const result = spawnSync('pipx', ['install', pkgName], {
372
375
  stdio: 'inherit',
373
- timeout: 60000 // HIGH #1 fix - 60 second timeout
376
+ timeout: 60000
374
377
  });
375
378
 
376
- // Verify installation actually worked (proves it's installed)
377
- const validation = await validateProvider(providerName);
378
- if (validation.installed) {
379
- return {
380
- success: true,
381
- message: `Successfully installed via pipx`,
382
- command: `pipx install ${pkgName}`,
383
- verified: true
384
- };
385
- }
379
+ if (result.error || result.status !== 0) {
380
+ // Both strategies failed
381
+ } else {
382
+ // Verify installation actually worked (proves it's installed)
383
+ const validation = await validateProvider(providerName);
384
+ if (validation.installed) {
385
+ return {
386
+ success: true,
387
+ message: `Successfully installed via pipx`,
388
+ command: `pipx install ${pkgName}`,
389
+ verified: true
390
+ };
391
+ }
386
392
 
387
- return { success: true, message: `Successfully installed via pipx`, command: `pipx install ${pkgName}` };
393
+ return { success: true, message: `Successfully installed via pipx`, command: `pipx install ${pkgName}` };
394
+ }
388
395
  } catch (error) {
389
396
  // Both strategies failed
390
397
  }
@@ -400,14 +407,19 @@ export async function attemptProviderInstallation(providerName) {
400
407
  */
401
408
  function getPackageInfo(pkgName) {
402
409
  try {
403
- // Small delay to ensure package is registered in pip
404
- // (sometimes pip needs a moment to update its cache)
405
- const output = execSync('pip', ['show', pkgName], {
410
+ // Use spawnSync with array args (security: correct API usage per CLAUDE.md)
411
+ const result = spawnSync('pip', ['show', pkgName], {
406
412
  encoding: 'utf8',
407
413
  timeout: 10000,
408
- stdio: ['pipe', 'pipe', 'pipe'] // Capture both stdout and stderr
414
+ stdio: ['pipe', 'pipe', 'pipe']
409
415
  });
410
416
 
417
+ if (result.error || result.status !== 0) {
418
+ return null;
419
+ }
420
+
421
+ const output = result.stdout;
422
+
411
423
  // Parse pip show output
412
424
  const info = {};
413
425
  const lines = output.split('\n');