agentvibes 3.5.8 → 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
@@ -77,7 +77,7 @@ elif [[ -f "$GLOBAL_MUTE_FILE" ]]; then
77
77
  exit 0
78
78
  fi
79
79
 
80
- TEXT="$1"
80
+ TEXT="${1:-}"
81
81
  VOICE_OVERRIDE="${2:-}" # Optional: voice name or ID
82
82
 
83
83
  # Security: Validate inputs
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.8
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.8 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)
@@ -134,6 +134,7 @@ All 50+ Piper voices AgentVibes provides are sourced from Hugging Face's open-so
134
134
  - [🗑️ Uninstalling](#️-uninstalling) - Remove AgentVibes cleanly
135
135
  - [❓ FAQ](#-frequently-asked-questions-faq) - **NEW!** Common questions answered (git-lfs, MCP tokens, installation)
136
136
  - [🍎 macOS Testing](docs/macos-testing.md) - Automated testing on macOS with GitHub Actions
137
+ - [🤗 Hugging Face Voice Models](docs/hugging-face-models.md) - Technical details on AI voice models
137
138
  - [🙏 Credits](#-credits) - Acknowledgments
138
139
  - [🤝 Contributing](#-contributing) - Show support
139
140
 
@@ -141,11 +142,18 @@ All 50+ Piper voices AgentVibes provides are sourced from Hugging Face's open-so
141
142
 
142
143
  ## 📰 Latest Release
143
144
 
144
- **[v3.5.8 - Security & Provider Validation](https://github.com/paulpreibisch/AgentVibes/releases/tag/v3.5.8)** 🛡️
145
+ **[v3.5.10-alpha.0 - Soprano Detection Fixes & Features](https://github.com/paulpreibisch/AgentVibes/releases/tag/master)** (Alpha) 🛡️
145
146
 
146
- 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
147
155
 
148
- **Foundation Release:** [v3.5.5 - Native Windows Support](https://github.com/paulpreibisch/AgentVibes/releases/tag/v3.5.5) 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.8 history.
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.
149
157
 
150
158
  💡 **Tip:** If `npx agentvibes` shows an older version or missing commands, clear your npm cache: `npm cache clean --force && npx agentvibes@latest --help`
151
159
 
@@ -1467,6 +1475,9 @@ Both do the exact same thing - MCP is more convenient, slash commands are more t
1467
1475
 
1468
1476
  **Powered by:**
1469
1477
  - [Piper TTS](https://github.com/rhasspy/piper) - Free neural voices
1478
+ - [Soprano TTS](https://github.com/suno-ai/bark) - Ultra-fast neural TTS
1479
+ - **Windows SAPI** - Native Windows text-to-speech
1480
+ - **macOS Say** - Native macOS text-to-speech
1470
1481
  - [Claude Code](https://claude.com/claude-code) - AI coding assistant
1471
1482
  - Licensed under Apache 2.0
1472
1483
 
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.8",
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: {
@@ -862,93 +1042,103 @@ async function collectConfiguration(options = {}) {
862
1042
  value: '__back__'
863
1043
  });
864
1044
 
865
- const { provider } = await inquirer.prompt([{
866
- type: 'list',
867
- name: 'provider',
868
- message: chalk.yellow('Select TTS provider:'),
869
- choices: providerChoices,
870
- default: config.provider || (isNativeWindows() ? 'windows-piper' : (isMacOS ? 'macos' : 'piper'))
871
- }]);
872
-
873
- // Check if user wants to go back
874
- if (provider === '__back__') {
875
- return null;
876
- }
877
-
878
- // Validate provider installation before accepting selection
879
- console.log(chalk.gray(`\n Checking for ${getProviderDisplayName(provider)}...`));
880
- const validation = await validateProvider(provider);
881
-
882
- if (!validation.installed) {
883
- const displayName = getProviderDisplayName(provider);
884
- console.log(chalk.yellow(`\n⚠️ ${validation.message}`));
885
-
886
- const { action } = await inquirer.prompt([{
1045
+ // Provider selection loop - allows user to try different providers without going back
1046
+ let providerSelected = false;
1047
+ while (!providerSelected) {
1048
+ const { provider } = await inquirer.prompt([{
887
1049
  type: 'list',
888
- name: 'action',
889
- message: 'What would you like to do?',
890
- choices: [
891
- { name: chalk.green('Install now (recommended)'), value: 'install' },
892
- { name: 'Choose a different provider', value: 'back' },
893
- { name: 'I\'ll install it myself later', value: 'skip' }
894
- ]
1050
+ name: 'provider',
1051
+ message: chalk.yellow('Select TTS provider:'),
1052
+ choices: providerChoices,
1053
+ default: config.provider || (isNativeWindows() ? 'windows-piper' : (isMacOS ? 'macos' : 'piper'))
895
1054
  }]);
896
1055
 
897
- if (action === 'install') {
898
- console.log(chalk.cyan(`\n📦 Installing ${displayName}...\n`));
899
-
900
- // Use smart installation with fallbacks
901
- const installResult = await attemptProviderInstallation(provider);
902
-
903
- if (installResult.success && installResult.verified) {
904
- // Installation succeeded AND verified
905
- console.log(chalk.green(`\n✓ ${displayName} installed and verified!\n`));
906
- console.log(chalk.gray(` Method: ${installResult.command}`));
907
- console.log(chalk.green(` Status: Ready to use\n`));
908
- } else if (installResult.success) {
909
- // Installation command ran but verification failed
910
- console.log(chalk.yellow(`\n⚠️ Installation command completed, but verification failed\n`));
911
- console.log(chalk.gray(` The installation may have been blocked by system protection (PEP 668).\n`));
912
- console.log(chalk.cyan(` Try one of these solutions:\n`));
913
- console.log(chalk.gray(` 1. Use pipx (avoids system protection):\n pipx install soprano-tts\n`));
914
- console.log(chalk.gray(` 2. Create a virtual environment:\n python3 -m venv ~/my-env\n ~/my-env/bin/pip install soprano-tts\n`));
915
-
916
- // Pause before returning to provider selection
917
- await inquirer.prompt([{
918
- type: 'confirm',
919
- name: 'continue',
920
- message: 'Press Enter to go back to provider selection',
921
- default: true
922
- }]);
923
-
924
- return null; // Go back to provider selection
925
- } else {
926
- console.log(chalk.red(`\n❌ ${installResult.message}\n`));
1056
+ // Check if user wants to go back to previous page
1057
+ if (provider === '__back__') {
1058
+ return null;
1059
+ }
927
1060
 
928
- // Pause before returning to provider selection
929
- await inquirer.prompt([{
930
- type: 'confirm',
931
- name: 'continue',
932
- message: 'Press Enter to go back to provider selection',
933
- default: true
934
- }]);
1061
+ // Validate provider installation before accepting selection
1062
+ console.log(chalk.gray(`\n Checking for ${getProviderDisplayName(provider)}...`));
1063
+ const validation = await validateProvider(provider);
1064
+
1065
+ if (!validation.installed) {
1066
+ const displayName = getProviderDisplayName(provider);
1067
+ console.log(chalk.yellow(`\n⚠️ ${validation.message}`));
1068
+
1069
+ const { action } = await inquirer.prompt([{
1070
+ type: 'list',
1071
+ name: 'action',
1072
+ message: 'What would you like to do?',
1073
+ choices: [
1074
+ { name: chalk.green('Install now (recommended)'), value: 'install' },
1075
+ { name: 'Choose a different provider', value: 'back' },
1076
+ { name: 'I\'ll install it myself later', value: 'skip' }
1077
+ ]
1078
+ }]);
935
1079
 
936
- return null; // Go back to provider selection
1080
+ if (action === 'install') {
1081
+ console.log(chalk.cyan(`\n📦 Installing ${displayName}...\n`));
1082
+
1083
+ // Use smart installation with fallbacks
1084
+ const installResult = await attemptProviderInstallation(provider);
1085
+
1086
+ if (installResult.success && installResult.verified) {
1087
+ // Installation succeeded AND verified
1088
+ console.log(chalk.green(`\n✓ ${displayName} installed and verified!\n`));
1089
+ console.log(chalk.gray(` Method: ${installResult.command}`));
1090
+ console.log(chalk.green(` Status: Ready to use\n`));
1091
+ config.provider = provider;
1092
+ providerSelected = true; // Exit provider selection loop
1093
+ } else if (installResult.success) {
1094
+ // Installation command ran but verification failed
1095
+ console.log(chalk.yellow(`\n⚠️ Installation command completed, but verification failed\n`));
1096
+ console.log(chalk.gray(` The installation may have been blocked by system protection (PEP 668).\n`));
1097
+ console.log(chalk.cyan(` Try one of these solutions:\n`));
1098
+ console.log(chalk.gray(` 1. Use pipx (avoids system protection):\n pipx install soprano-tts\n`));
1099
+ console.log(chalk.gray(` 2. Create a virtual environment:\n python3 -m venv ~/my-env\n ~/my-env/bin/pip install soprano-tts\n`));
1100
+
1101
+ // Pause before returning to provider selection
1102
+ await inquirer.prompt([{
1103
+ type: 'confirm',
1104
+ name: 'continue',
1105
+ message: 'Press Enter to try a different provider',
1106
+ default: true
1107
+ }]);
1108
+
1109
+ // Loop back to provider selection
1110
+ continue;
1111
+ } else {
1112
+ console.log(chalk.red(`\n❌ ${installResult.message}\n`));
1113
+
1114
+ // Pause before returning to provider selection
1115
+ await inquirer.prompt([{
1116
+ type: 'confirm',
1117
+ name: 'continue',
1118
+ message: 'Press Enter to try a different provider',
1119
+ default: true
1120
+ }]);
1121
+
1122
+ // Loop back to provider selection
1123
+ continue;
1124
+ }
1125
+ } else if (action === 'back') {
1126
+ // Loop back to provider selection to choose a different one
1127
+ continue;
1128
+ } else if (action === 'skip') {
1129
+ console.log(chalk.yellow(`\n⚠️ No problem! You can set it up anytime with:\n ${getProviderInstallCommand(provider)}\n`));
1130
+ config.provider = provider;
1131
+ providerSelected = true; // Exit provider selection loop
937
1132
  }
938
- } else if (action === 'back') {
939
- // Go back to provider selection
940
- return null;
941
- } else if (action === 'skip') {
942
- console.log(chalk.yellow(`\n⚠️ No problem! You can set it up anytime with:\n ${getProviderInstallCommand(provider)}\n`));
1133
+ } else {
1134
+ // Provider detected and ready to use
1135
+ const displayName = getProviderDisplayName(provider);
1136
+ console.log(chalk.green(`\n✓ ${displayName} Detected and selected!\n`));
1137
+ config.provider = provider;
1138
+ providerSelected = true; // Exit provider selection loop
943
1139
  }
944
- } else {
945
- // Provider detected and ready to use
946
- const displayName = getProviderDisplayName(provider);
947
- console.log(chalk.green(`\n✓ ${displayName} Detected and selected!\n`));
948
1140
  }
949
1141
 
950
- config.provider = provider;
951
-
952
1142
  // Handle special receiver mode for Termux
953
1143
  if (config.provider === 'piper-receiver') {
954
1144
  config.provider = 'piper';
@@ -1304,7 +1494,37 @@ async function collectConfiguration(options = {}) {
1304
1494
  }
1305
1495
 
1306
1496
  } else if (currentPage === 3) {
1307
- // 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
1308
1528
  console.log(boxen(
1309
1529
  chalk.white('Give your Agent a personality!\n\n') +
1310
1530
  chalk.gray('Personalities add character and style to TTS responses.\n') +
@@ -1349,16 +1569,18 @@ async function collectConfiguration(options = {}) {
1349
1569
  personalities.sort((a, b) => a.name.localeCompare(b.name));
1350
1570
 
1351
1571
  // Add "none" as first option (default)
1572
+ const noneEmoji = personalityEmojis['none'] || '✨';
1352
1573
  personalityChoices.push(
1353
- { 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' },
1354
1575
  new inquirer.Separator(chalk.gray('─'.repeat(60)))
1355
1576
  );
1356
1577
 
1357
1578
  // Add all other personalities
1358
1579
  for (const p of personalities) {
1359
1580
  if (p.name !== 'normal') { // Skip 'normal' as it's similar to 'none'
1581
+ const emoji = personalityEmojis[p.name] || '✨';
1360
1582
  personalityChoices.push({
1361
- name: chalk.cyan(p.name) + chalk.gray(` - ${p.description}`),
1583
+ name: emoji + ' ' + chalk.cyan(p.name) + chalk.gray(` - ${p.description}`),
1362
1584
  value: p.name
1363
1585
  });
1364
1586
  }
@@ -1377,14 +1599,23 @@ async function collectConfiguration(options = {}) {
1377
1599
  ];
1378
1600
  }
1379
1601
 
1380
- const { selectedPersonality } = await inquirer.prompt([{
1381
- type: 'list',
1382
- name: 'selectedPersonality',
1383
- message: chalk.yellow('Select your default personality:'),
1384
- choices: personalityChoices,
1385
- 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
+ },
1386
1617
  pageSize: 15
1387
- }]);
1618
+ });
1388
1619
 
1389
1620
  if (selectedPersonality === '__back__') {
1390
1621
  currentPage--; // Go back to voice selection
@@ -1504,6 +1735,13 @@ async function collectConfiguration(options = {}) {
1504
1735
  console.log('');
1505
1736
  console.log(chalk.gray('🎼 Choose your default background music genre (you can change this anytime).'));
1506
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
+
1507
1745
  const trackChoices = [
1508
1746
  { name: '🎻 Soft Flamenco (Spanish guitar)', value: 'agentvibes_soft_flamenco_loop.mp3' },
1509
1747
  { name: '🎺 Bachata (Latin - Romantic guitar & bongos)', value: 'agent_vibes_bachata_v1_loop.mp3' },
@@ -1523,20 +1761,85 @@ async function collectConfiguration(options = {}) {
1523
1761
  { name: '🥁 Tabla Dream Pop (Indian percussion)', value: 'agent_vibes_tabla_dream_pop_v1_loop.mp3' }
1524
1762
  ];
1525
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
+
1526
1778
  const { selectedTrack } = await inquirer.prompt([{
1527
1779
  type: 'list',
1528
1780
  name: 'selectedTrack',
1529
1781
  message: chalk.yellow('Choose default background music track:'),
1530
1782
  choices: trackChoices,
1531
1783
  default: config.backgroundMusic.track || 'agentvibes_soft_flamenco_loop.mp3',
1532
- pageSize: 16
1784
+ pageSize: 18
1785
+ }]);
1786
+
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
1533
1832
  }]);
1534
1833
 
1535
- config.backgroundMusic.track = selectedTrack;
1834
+ if (previewTrack) {
1835
+ console.log('');
1836
+ await previewAudioTrack(config.backgroundMusic.track, tracksDir);
1837
+ console.log('');
1838
+ }
1536
1839
  }
1537
1840
 
1538
1841
  // Auto-advance to next page after audio settings
1539
- console.log(chalk.green('\n✓ Audio settings configured\n'));
1842
+ console.log(chalk.green('✓ Audio settings configured\n'));
1540
1843
  currentPage++;
1541
1844
  continue;
1542
1845
 
@@ -2499,30 +2802,6 @@ async function copyPersonalityFiles(targetDir, spinner) {
2499
2802
  content += chalk.gray('Personalities change how Claude speaks - adding humor, emotion, or style.\n');
2500
2803
  content += chalk.gray('Change with: ') + chalk.yellow('/agent-vibes:personality <name>') + chalk.gray(' or say "change personality to sassy"\n\n');
2501
2804
 
2502
- // Map personalities to emojis
2503
- const personalityEmojis = {
2504
- 'angry': '😠',
2505
- 'annoying': '😤',
2506
- 'crass': '🤬',
2507
- 'dramatic': '🎭',
2508
- 'dry-humor': '😐',
2509
- 'flirty': '😘',
2510
- 'funny': '😂',
2511
- 'grandpa': '👴',
2512
- 'millennial': '🙄',
2513
- 'moody': '😒',
2514
- 'normal': '😊',
2515
- 'pirate': '🏴‍☠️',
2516
- 'poetic': '📜',
2517
- 'professional': '👔',
2518
- 'rapper': '🎤',
2519
- 'robot': '🤖',
2520
- 'sarcastic': '😏',
2521
- 'sassy': '💁',
2522
- 'surfer-dude': '🏄',
2523
- 'zen': '🧘'
2524
- };
2525
-
2526
2805
  // Display personalities in two columns
2527
2806
  const personalities = installedPersonalities.map(file => {
2528
2807
  const name = file.replace('.md', '');
@@ -4377,6 +4656,14 @@ Troubleshooting:
4377
4656
  await fs.writeFile(personalityFile, userConfig.personality);
4378
4657
  }
4379
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
+
4380
4667
  // Initialize piperVoicesBoxen outside the conditional for proper scoping
4381
4668
  let piperVoicesBoxen = null;
4382
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');