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.
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/play-tts.sh +1 -1
- package/README.md +16 -5
- package/package.json +3 -1
- package/src/installer.js +402 -115
- package/src/utils/provider-validator.js +144 -132
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
20260214
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
[](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml)
|
|
12
12
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
13
13
|
|
|
14
|
-
**Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v3.5.
|
|
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.
|
|
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.
|
|
145
|
+
**[v3.5.10-alpha.0 - Soprano Detection Fixes & Features](https://github.com/paulpreibisch/AgentVibes/releases/tag/master)** (Alpha) 🛡️
|
|
145
146
|
|
|
146
|
-
Critical
|
|
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.
|
|
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.
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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: '
|
|
889
|
-
message: '
|
|
890
|
-
choices:
|
|
891
|
-
|
|
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
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
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
|
|
939
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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:
|
|
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
|
-
|
|
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('
|
|
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,
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* @
|
|
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
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
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
|
|
80
|
-
const result =
|
|
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:
|
|
91
|
-
|
|
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:
|
|
106
|
-
error:
|
|
107
|
-
|
|
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
|
|
114
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
346
|
+
const result = spawnSync('pip', ['install', pkgName], {
|
|
347
347
|
stdio: 'inherit',
|
|
348
|
-
timeout: 60000
|
|
348
|
+
timeout: 60000
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
+
const result = spawnSync('pipx', ['install', pkgName], {
|
|
372
375
|
stdio: 'inherit',
|
|
373
|
-
timeout: 60000
|
|
376
|
+
timeout: 60000
|
|
374
377
|
});
|
|
375
378
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
404
|
-
|
|
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']
|
|
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');
|