agentvibes 5.7.7 → 5.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agentvibes/config.json +0 -2
- package/.claude/config/audio-effects.cfg +4 -4
- package/.claude/config/background-music-enabled.txt +1 -0
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/play-tts-piper.sh +20 -13
- package/.claude/hooks/play-tts-ssh-remote.sh +2 -2
- package/.claude/hooks/voice-manager.sh +6 -0
- package/.claude/hooks-windows/audio-cache-utils.ps1 +119 -119
- package/.claude/hooks-windows/bmad-speak.ps1 +9 -38
- package/.claude/hooks-windows/play-tts-soprano.ps1 +13 -2
- package/.claude/hooks-windows/play-tts-windows-piper.ps1 +22 -16
- package/.mcp.json +13 -9
- package/README.md +33 -2
- package/RELEASE_NOTES.md +80 -0
- package/mcp-server/server.py +17 -7
- package/package.json +2 -2
- package/src/commands/install-mcp.js +270 -16
- package/src/console/app.js +3 -3
- package/src/console/audio-env.js +4 -1
- package/src/console/tabs/agents-tab.js +89 -66
- package/src/console/tabs/music-tab.js +4 -3
- package/src/console/tabs/receiver-tab.js +13 -13
- package/src/console/tabs/settings-tab.js +2 -2
- package/src/console/tabs/setup-tab.js +291 -47
- package/src/console/tabs/voices-tab.js +17 -5
- package/src/console/widgets/personality-picker.js +2 -2
- package/src/console/widgets/reverb-picker.js +1 -1
- package/src/installer.js +32 -27
- package/src/services/provider-service.js +1 -1
- package/src/services/tts-engine-service.js +2 -2
- package/src/utils/audio-duration-validator.js +2 -2
- package/src/utils/list-formatter.js +9 -3
- package/src/utils/platform-resolver.js +369 -0
- package/src/utils/provider-validator.js +9 -9
- package/.agentvibes/install-manifest.json +0 -442
- package/.claude/config/background-music-position.txt +0 -27
- package/.claude/config/background-music-volume.txt +0 -1
- package/.claude/config/background-music.cfg +0 -1
- package/.claude/config/background-music.txt +0 -1
- package/.claude/config/reverb-level.txt +0 -1
- package/.claude/config/tts-speech-rate.txt +0 -1
- package/.claude/config/tts-verbosity.txt +0 -1
- package/.claude/hooks/bmad-party-manager.sh +0 -225
- package/.claude/hooks/stop.sh +0 -38
- package/.claude/piper-voices-dir.txt +0 -1
- /package/.claude/audio/tracks/{CelestialVelvet.mp3 → celestial_velvet.mp3} +0 -0
package/src/installer.js
CHANGED
|
@@ -383,14 +383,14 @@ function isPiperInstalled() {
|
|
|
383
383
|
}
|
|
384
384
|
// Also check PATH (e.g. pip-installed piper)
|
|
385
385
|
try {
|
|
386
|
-
execSync('where piper.exe', { stdio: 'pipe', timeout: 3000 });
|
|
386
|
+
execSync('where piper.exe', { stdio: 'pipe', timeout: 3000 }); // NOSONAR
|
|
387
387
|
return true;
|
|
388
388
|
} catch (e) {
|
|
389
389
|
return false;
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
392
|
try {
|
|
393
|
-
execSync('which piper', {
|
|
393
|
+
execSync('which piper', { // NOSONAR
|
|
394
394
|
stdio: 'pipe',
|
|
395
395
|
timeout: 3000
|
|
396
396
|
});
|
|
@@ -406,7 +406,7 @@ function isPiperInstalled() {
|
|
|
406
406
|
*/
|
|
407
407
|
function isSopranoInstalled() {
|
|
408
408
|
try {
|
|
409
|
-
execSync('which soprano-tts || which soprano-webui', {
|
|
409
|
+
execSync('which soprano-tts || which soprano-webui', { // NOSONAR
|
|
410
410
|
stdio: 'pipe',
|
|
411
411
|
timeout: 3000
|
|
412
412
|
});
|
|
@@ -415,7 +415,7 @@ function isSopranoInstalled() {
|
|
|
415
415
|
// On Windows, 'which' may not find Python scripts; try 'py -m pip show' as fallback
|
|
416
416
|
if (isNativeWindows()) {
|
|
417
417
|
try {
|
|
418
|
-
const result = spawnSync('py', ['-m', 'pip', 'show', 'soprano-tts'], {
|
|
418
|
+
const result = spawnSync('py', ['-m', 'pip', 'show', 'soprano-tts'], { // NOSONAR
|
|
419
419
|
encoding: 'utf8',
|
|
420
420
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
421
421
|
timeout: 10000
|
|
@@ -454,7 +454,7 @@ async function playVoiceSample(voiceName, provider) {
|
|
|
454
454
|
// Play using sox/aplay - use spawn for non-blocking playback
|
|
455
455
|
try {
|
|
456
456
|
// Play using aplay directly (no shell interpolation — prevents command injection)
|
|
457
|
-
const player = spawn('aplay', [sampleFile], {
|
|
457
|
+
const player = spawn('aplay', [sampleFile], { // NOSONAR
|
|
458
458
|
detached: false,
|
|
459
459
|
stdio: 'ignore'
|
|
460
460
|
});
|
|
@@ -470,7 +470,7 @@ async function playVoiceSample(voiceName, provider) {
|
|
|
470
470
|
if (isPiperProvider(provider) && isPiperInstalled()) {
|
|
471
471
|
const text = `Hi, I'm ${voiceName.split('-')[1] || 'Piper'}`;
|
|
472
472
|
// Use bash -c with positional args to prevent command injection via text/voiceName
|
|
473
|
-
spawnSync('bash', ['-c', 'echo "$1" | piper --model "$2" --output_raw | aplay -r 22050 -f S16_LE -t raw -', '_', text, voiceName], {
|
|
473
|
+
spawnSync('bash', ['-c', 'echo "$1" | piper --model "$2" --output_raw | aplay -r 22050 -f S16_LE -t raw -', '_', text, voiceName], { // NOSONAR
|
|
474
474
|
stdio: 'inherit',
|
|
475
475
|
timeout: 15000
|
|
476
476
|
});
|
|
@@ -492,7 +492,7 @@ async function playVoiceSample(voiceName, provider) {
|
|
|
492
492
|
fsSync.writeFileSync(tempFile, Buffer.from(audio));
|
|
493
493
|
try {
|
|
494
494
|
// Use spawnSync with argument array to prevent command injection
|
|
495
|
-
spawnSync('aplay', [tempFile], { stdio: 'pipe', timeout: 5000 });
|
|
495
|
+
spawnSync('aplay', [tempFile], { stdio: 'pipe', timeout: 5000 }); // NOSONAR
|
|
496
496
|
} finally {
|
|
497
497
|
fsSync.unlinkSync(tempFile);
|
|
498
498
|
}
|
|
@@ -531,7 +531,7 @@ async function startSopranoServer() {
|
|
|
531
531
|
console.log(chalk.gray('🚀 Starting Soprano TTS server...'));
|
|
532
532
|
|
|
533
533
|
// Start soprano-webui in background
|
|
534
|
-
const sopranoProcess = spawn('soprano-webui', ['--port', '7860'], {
|
|
534
|
+
const sopranoProcess = spawn('soprano-webui', ['--port', '7860'], { // NOSONAR
|
|
535
535
|
detached: true,
|
|
536
536
|
stdio: 'ignore'
|
|
537
537
|
});
|
|
@@ -569,7 +569,7 @@ async function detectSystemCapabilities() {
|
|
|
569
569
|
try {
|
|
570
570
|
// Detect NVIDIA GPU
|
|
571
571
|
try {
|
|
572
|
-
execSync('nvidia-smi --query-gpu=name --format=csv,noheader', {
|
|
572
|
+
execSync('nvidia-smi --query-gpu=name --format=csv,noheader', { // NOSONAR
|
|
573
573
|
stdio: 'pipe',
|
|
574
574
|
timeout: 5000 // 5 second timeout
|
|
575
575
|
});
|
|
@@ -580,7 +580,7 @@ async function detectSystemCapabilities() {
|
|
|
580
580
|
|
|
581
581
|
// Detect total RAM (in MB)
|
|
582
582
|
if (isMacOS) {
|
|
583
|
-
const output = execSync('sysctl hw.memsize', {
|
|
583
|
+
const output = execSync('sysctl hw.memsize', { // NOSONAR
|
|
584
584
|
encoding: 'utf8',
|
|
585
585
|
timeout: 3000 // 3 second timeout
|
|
586
586
|
});
|
|
@@ -594,7 +594,7 @@ async function detectSystemCapabilities() {
|
|
|
594
594
|
}
|
|
595
595
|
totalRAM = Math.floor(bytes / (1024 * 1024));
|
|
596
596
|
} else {
|
|
597
|
-
const output = execSync('cat /proc/meminfo | grep MemTotal', {
|
|
597
|
+
const output = execSync('cat /proc/meminfo | grep MemTotal', { // NOSONAR
|
|
598
598
|
encoding: 'utf8',
|
|
599
599
|
timeout: 3000 // 3 second timeout
|
|
600
600
|
});
|
|
@@ -679,12 +679,12 @@ function checkAudioDevices() {
|
|
|
679
679
|
if (process.platform === 'linux') {
|
|
680
680
|
try {
|
|
681
681
|
// Try aplay first (ALSA)
|
|
682
|
-
execSync('aplay -l 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' });
|
|
682
|
+
execSync('aplay -l 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' }); // NOSONAR
|
|
683
683
|
return true;
|
|
684
684
|
} catch (e) {
|
|
685
685
|
// Try paplay (PulseAudio)
|
|
686
686
|
try {
|
|
687
|
-
execSync('paplay --version 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' });
|
|
687
|
+
execSync('paplay --version 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' }); // NOSONAR
|
|
688
688
|
return true;
|
|
689
689
|
} catch (e2) {
|
|
690
690
|
return false;
|
|
@@ -936,7 +936,7 @@ async function handleSystemDependenciesPage() {
|
|
|
936
936
|
|
|
937
937
|
if (piperInstalled) {
|
|
938
938
|
try {
|
|
939
|
-
const piperPath = execSync('which piper 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
939
|
+
const piperPath = execSync('which piper 2>/dev/null', { encoding: 'utf8' }).trim(); // NOSONAR
|
|
940
940
|
depContent += chalk.green('✓ Piper TTS (offline voice synthesis)\n');
|
|
941
941
|
depContent += chalk.gray(` ${piperPath}\n`);
|
|
942
942
|
} catch (e) {
|
|
@@ -947,9 +947,9 @@ async function handleSystemDependenciesPage() {
|
|
|
947
947
|
try {
|
|
948
948
|
let sopranoPath = '';
|
|
949
949
|
try {
|
|
950
|
-
sopranoPath = execSync('which soprano-tts 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
950
|
+
sopranoPath = execSync('which soprano-tts 2>/dev/null', { encoding: 'utf8' }).trim(); // NOSONAR
|
|
951
951
|
} catch (e) {
|
|
952
|
-
sopranoPath = execSync('which soprano-webui 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
952
|
+
sopranoPath = execSync('which soprano-webui 2>/dev/null', { encoding: 'utf8' }).trim(); // NOSONAR
|
|
953
953
|
}
|
|
954
954
|
depContent += chalk.green('✓ Soprano TTS (premium quality)\n');
|
|
955
955
|
depContent += chalk.gray(` ${sopranoPath}\n`);
|
|
@@ -2244,7 +2244,7 @@ async function collectConfiguration(options = {}) {
|
|
|
2244
2244
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
2245
2245
|
|
|
2246
2246
|
// Extract description from frontmatter
|
|
2247
|
-
const descMatch = content.match(/^description:\s*(.+)$/m);
|
|
2247
|
+
const descMatch = content.match(/^description:\s*(.+)$/m); // NOSONAR
|
|
2248
2248
|
const description = descMatch ? descMatch[1].trim() : '';
|
|
2249
2249
|
|
|
2250
2250
|
personalities.push({
|
|
@@ -2958,7 +2958,7 @@ function getReleaseInfoBoxen() {
|
|
|
2958
2958
|
.replace(/^#{1,6}\s*/, '') // ## headings
|
|
2959
2959
|
.replace(/\*\*([^*]+)\*\*/g, '$1') // **bold**
|
|
2960
2960
|
.replace(/`([^`]+)`/g, '$1') // `code`
|
|
2961
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [text](url)
|
|
2961
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [text](url) // NOSONAR
|
|
2962
2962
|
|
|
2963
2963
|
// Render: heading in cyan, bullets as gray, skip blank/hr lines at end
|
|
2964
2964
|
return sectionLines
|
|
@@ -3007,7 +3007,7 @@ async function playWelcomeDemo(targetDir, spinner, options = {}) {
|
|
|
3007
3007
|
if (isNativeWindows()) {
|
|
3008
3008
|
// Escape single quotes to prevent PowerShell injection (double them per PS escaping rules)
|
|
3009
3009
|
const safeAudioPath = welcomeDemoAudio.replace(/'/g, "''");
|
|
3010
|
-
const audioProcess = spawn('powershell', [
|
|
3010
|
+
const audioProcess = spawn('powershell', [ // NOSONAR
|
|
3011
3011
|
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
|
|
3012
3012
|
`(New-Object System.Media.SoundPlayer '${safeAudioPath}').PlaySync()`
|
|
3013
3013
|
], { detached: true, stdio: 'ignore' });
|
|
@@ -3019,15 +3019,15 @@ async function playWelcomeDemo(targetDir, spinner, options = {}) {
|
|
|
3019
3019
|
let audioPlayer = null;
|
|
3020
3020
|
|
|
3021
3021
|
try {
|
|
3022
|
-
execFileSync('which', ['paplay'], { stdio: 'pipe' });
|
|
3022
|
+
execFileSync('which', ['paplay'], { stdio: 'pipe' }); // NOSONAR
|
|
3023
3023
|
audioPlayer = 'paplay';
|
|
3024
3024
|
} catch {
|
|
3025
3025
|
try {
|
|
3026
|
-
execFileSync('which', ['afplay'], { stdio: 'pipe' });
|
|
3026
|
+
execFileSync('which', ['afplay'], { stdio: 'pipe' }); // NOSONAR
|
|
3027
3027
|
audioPlayer = 'afplay';
|
|
3028
3028
|
} catch {
|
|
3029
3029
|
try {
|
|
3030
|
-
execFileSync('which', ['mpv'], { stdio: 'pipe' });
|
|
3030
|
+
execFileSync('which', ['mpv'], { stdio: 'pipe' }); // NOSONAR
|
|
3031
3031
|
audioPlayer = 'mpv';
|
|
3032
3032
|
} catch {}
|
|
3033
3033
|
}
|
|
@@ -4907,7 +4907,7 @@ async function executeMigrationScript(migrationScript, targetDir, spinner) {
|
|
|
4907
4907
|
|
|
4908
4908
|
// Execute migration script using execFileSync to prevent command injection
|
|
4909
4909
|
// Uses top-level import of execFileSync (ESM-compatible, no require())
|
|
4910
|
-
execFileSync('bash', [migrationScript], { cwd: targetDir, stdio: 'pipe' });
|
|
4910
|
+
execFileSync('bash', [migrationScript], { cwd: targetDir, stdio: 'pipe' }); // NOSONAR
|
|
4911
4911
|
|
|
4912
4912
|
spinner.succeed(chalk.green('✓ Configuration migrated to .agentvibes/'));
|
|
4913
4913
|
console.log(chalk.gray(' Old locations: .claude/config/, .claude/plugins/'));
|
|
@@ -5298,7 +5298,7 @@ async function restartWatcherIfInstalled(homeDirOverride) {
|
|
|
5298
5298
|
const { spawnSync, spawn } = require('child_process');
|
|
5299
5299
|
|
|
5300
5300
|
// Kill old watcher — use array args to avoid quoting issues
|
|
5301
|
-
spawnSync('powershell.exe', [
|
|
5301
|
+
spawnSync('powershell.exe', [ // NOSONAR
|
|
5302
5302
|
'-NoProfile', '-Command',
|
|
5303
5303
|
'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like \'*tts-watcher.ps1*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }'
|
|
5304
5304
|
], { stdio: 'ignore', timeout: 8000 });
|
|
@@ -5315,9 +5315,9 @@ async function restartWatcherIfInstalled(homeDirOverride) {
|
|
|
5315
5315
|
// Restart via VBS launcher (hidden, no console flash) or fall back to direct spawn
|
|
5316
5316
|
try {
|
|
5317
5317
|
await fs.access(vbsLauncher);
|
|
5318
|
-
spawnSync('wscript.exe', [vbsLauncher], { stdio: 'ignore' });
|
|
5318
|
+
spawnSync('wscript.exe', [vbsLauncher], { stdio: 'ignore' }); // NOSONAR
|
|
5319
5319
|
} catch {
|
|
5320
|
-
const ps = spawn('powershell.exe', [
|
|
5320
|
+
const ps = spawn('powershell.exe', [ // NOSONAR
|
|
5321
5321
|
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', watcherDest
|
|
5322
5322
|
], { detached: true, stdio: 'ignore' });
|
|
5323
5323
|
ps.unref();
|
|
@@ -5782,7 +5782,7 @@ Troubleshooting:
|
|
|
5782
5782
|
const validReverb = ['light', 'medium', 'heavy', 'cathedral'];
|
|
5783
5783
|
if (validReverb.includes(selectedReverb)) {
|
|
5784
5784
|
try {
|
|
5785
|
-
execFileSync('bash', [effectsManagerPath, 'set-reverb', selectedReverb, 'default'], { stdio: 'pipe' });
|
|
5785
|
+
execFileSync('bash', [effectsManagerPath, 'set-reverb', selectedReverb, 'default'], { stdio: 'pipe' }); // NOSONAR
|
|
5786
5786
|
} catch {
|
|
5787
5787
|
// Reverb setting failed — non-fatal
|
|
5788
5788
|
}
|
|
@@ -6721,4 +6721,9 @@ export {
|
|
|
6721
6721
|
// Manifest utilities (used by tests and external tooling)
|
|
6722
6722
|
getProjectManifestPath, getGlobalManifestPath,
|
|
6723
6723
|
loadManifest, saveManifest, computeFileHash, manifestSafeCopy, removeManifestFiles,
|
|
6724
|
+
// Pure helper functions exported for testing
|
|
6725
|
+
isPiperProvider, supportsEmoji, getPersonalityIcon,
|
|
6726
|
+
detectEnvironment, createPageHeaderFooter, buildNavigationChoices, handleNavigationAction, getPageTitle,
|
|
6727
|
+
getUserShell, showWelcome, getReleaseInfoBoxen, generateActivationInstructions,
|
|
6728
|
+
collectConfiguration,
|
|
6724
6729
|
};
|
|
@@ -132,7 +132,7 @@ export class ProviderService {
|
|
|
132
132
|
*/
|
|
133
133
|
_isAvailable(binary) {
|
|
134
134
|
try {
|
|
135
|
-
execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 });
|
|
135
|
+
execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
|
|
136
136
|
return true;
|
|
137
137
|
} catch {
|
|
138
138
|
return false;
|
|
@@ -49,10 +49,10 @@ export function checkEngineInstalled(engineId) {
|
|
|
49
49
|
(process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
|
|
50
50
|
if (localAppData && fs.existsSync(path.join(localAppData, 'Programs', 'Piper', 'piper.exe'))) return true;
|
|
51
51
|
}
|
|
52
|
-
execFileSync('where', [binary], { stdio: 'ignore', timeout: 2000 });
|
|
52
|
+
execFileSync('where', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
|
|
53
53
|
return true;
|
|
54
54
|
}
|
|
55
|
-
execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 });
|
|
55
|
+
execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
|
|
56
56
|
return true;
|
|
57
57
|
} catch {
|
|
58
58
|
// try next binary
|
|
@@ -61,7 +61,7 @@ export async function getAudioDuration(filePath) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Check if ffprobe is available
|
|
64
|
-
const checkFFprobe = spawn('which', ['ffprobe']);
|
|
64
|
+
const checkFFprobe = spawn('which', ['ffprobe']); // NOSONAR
|
|
65
65
|
let ffprobeExists = false;
|
|
66
66
|
|
|
67
67
|
checkFFprobe.on('close', (code) => {
|
|
@@ -78,7 +78,7 @@ export async function getAudioDuration(filePath) {
|
|
|
78
78
|
|
|
79
79
|
// Use ffprobe to get duration (part of ffmpeg)
|
|
80
80
|
// SECURITY: Use spawn with args array to prevent command injection
|
|
81
|
-
const ffprobe = spawn('ffprobe', [
|
|
81
|
+
const ffprobe = spawn('ffprobe', [ // NOSONAR
|
|
82
82
|
'-v', 'error',
|
|
83
83
|
'-show_entries', 'format=duration',
|
|
84
84
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
@@ -71,9 +71,15 @@ export function formatVoicesList(voices, options = {}) {
|
|
|
71
71
|
} = options;
|
|
72
72
|
|
|
73
73
|
if (voices.length === 0) {
|
|
74
|
-
const content =
|
|
75
|
-
chalk.
|
|
76
|
-
chalk.
|
|
74
|
+
const content =
|
|
75
|
+
chalk.yellow('No voices downloaded yet') + '\n\n' +
|
|
76
|
+
chalk.white('AgentVibes includes 900+ free voices.\n') +
|
|
77
|
+
chalk.white('Get started with a default voice:\n\n') +
|
|
78
|
+
chalk.cyan(' /agent-vibes:add\n\n') +
|
|
79
|
+
chalk.gray('Or browse the full voice library:\n') +
|
|
80
|
+
chalk.cyan(' /agent-vibes:list\n\n') +
|
|
81
|
+
chalk.dim('Piper installed but voices missing? Run:\n') +
|
|
82
|
+
chalk.dim(' /agent-vibes:provider download en_US-lessac-medium');
|
|
77
83
|
|
|
78
84
|
return boxen(content, {
|
|
79
85
|
padding: 1,
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes Cross-Platform Resolver
|
|
3
|
+
*
|
|
4
|
+
* Implements the Agent Vibes Cross-Platform Contract v1.0.
|
|
5
|
+
* Single source of truth for binary resolution, path conventions, and env var interface.
|
|
6
|
+
*
|
|
7
|
+
* Resolution order (authoritative):
|
|
8
|
+
* 1. ENV_OVERRIDE — AGENTVIBES_*_PATH env var; if invalid, fail HARD (no fallthrough)
|
|
9
|
+
* 2. which/where — first result that passes validation
|
|
10
|
+
* 3. HINT_PATHS — platform hint list in order; first valid wins
|
|
11
|
+
* 4. FAIL — structured error, exit code 2
|
|
12
|
+
*
|
|
13
|
+
* Supported platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64, win32-x64
|
|
14
|
+
* WSL2 is treated as linux-x64.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'child_process';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
|
|
22
|
+
// ─── Platform Detection ───────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function isWSL() {
|
|
25
|
+
try {
|
|
26
|
+
return /microsoft|wsl/i.test(fs.readFileSync('/proc/version', 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Detect the current platform ID.
|
|
34
|
+
* Set AGENTVIBES_PLATFORM to override (CI / testing only).
|
|
35
|
+
* @returns {string} One of: darwin-arm64, darwin-x64, linux-x64, linux-arm64, win32-x64, unknown
|
|
36
|
+
*/
|
|
37
|
+
export function detectPlatform() {
|
|
38
|
+
const forced = process.env.AGENTVIBES_PLATFORM;
|
|
39
|
+
if (forced) return forced;
|
|
40
|
+
|
|
41
|
+
const p = process.platform;
|
|
42
|
+
const arch = process.arch;
|
|
43
|
+
|
|
44
|
+
if (p === 'linux' && isWSL()) return arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
|
|
45
|
+
if (p === 'darwin') return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
|
46
|
+
if (p === 'linux') return arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
|
|
47
|
+
if (p === 'win32') return 'win32-x64';
|
|
48
|
+
return 'unknown';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Path Hint Lists (only hardcoded paths in the entire codebase) ────────────
|
|
52
|
+
|
|
53
|
+
const PIPER_HINTS = {
|
|
54
|
+
'darwin-arm64': () => [
|
|
55
|
+
path.join(os.homedir(), '.agentvibes', 'bin', 'piper'),
|
|
56
|
+
path.join(os.homedir(), '.local', 'bin', 'piper'),
|
|
57
|
+
path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin', 'piper'),
|
|
58
|
+
'/opt/homebrew/bin/piper',
|
|
59
|
+
'/usr/local/bin/piper',
|
|
60
|
+
],
|
|
61
|
+
'darwin-x64': () => [
|
|
62
|
+
path.join(os.homedir(), '.agentvibes', 'bin', 'piper'),
|
|
63
|
+
path.join(os.homedir(), '.local', 'bin', 'piper'),
|
|
64
|
+
path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin', 'piper'),
|
|
65
|
+
'/usr/local/bin/piper',
|
|
66
|
+
'/opt/homebrew/bin/piper',
|
|
67
|
+
],
|
|
68
|
+
'linux-x64': () => [
|
|
69
|
+
path.join(os.homedir(), '.agentvibes', 'bin', 'piper'),
|
|
70
|
+
path.join(os.homedir(), '.local', 'bin', 'piper'),
|
|
71
|
+
path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin', 'piper'),
|
|
72
|
+
'/usr/bin/piper',
|
|
73
|
+
'/usr/local/bin/piper',
|
|
74
|
+
'/snap/bin/piper',
|
|
75
|
+
],
|
|
76
|
+
'linux-arm64': () => [
|
|
77
|
+
path.join(os.homedir(), '.agentvibes', 'bin', 'piper'),
|
|
78
|
+
path.join(os.homedir(), '.local', 'bin', 'piper'),
|
|
79
|
+
path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin', 'piper'),
|
|
80
|
+
'/usr/bin/piper',
|
|
81
|
+
'/usr/local/bin/piper',
|
|
82
|
+
],
|
|
83
|
+
'win32-x64': () => {
|
|
84
|
+
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
85
|
+
const localappdata = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
86
|
+
const programfiles = process.env.PROGRAMFILES || path.join('C:', 'Program Files');
|
|
87
|
+
return [
|
|
88
|
+
path.join(appdata, 'AgentVibes', 'bin', 'piper.exe'),
|
|
89
|
+
path.join(localappdata, 'AgentVibes', 'bin', 'piper.exe'),
|
|
90
|
+
path.join(programfiles, 'AgentVibes', 'bin', 'piper.exe'),
|
|
91
|
+
];
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const FFMPEG_HINTS = {
|
|
96
|
+
'darwin-arm64': () => [
|
|
97
|
+
path.join(os.homedir(), '.agentvibes', 'bin', 'ffmpeg'),
|
|
98
|
+
'/opt/homebrew/bin/ffmpeg',
|
|
99
|
+
'/usr/local/bin/ffmpeg',
|
|
100
|
+
],
|
|
101
|
+
'darwin-x64': () => [
|
|
102
|
+
path.join(os.homedir(), '.agentvibes', 'bin', 'ffmpeg'),
|
|
103
|
+
'/usr/local/bin/ffmpeg',
|
|
104
|
+
'/opt/homebrew/bin/ffmpeg',
|
|
105
|
+
],
|
|
106
|
+
'linux-x64': () => [
|
|
107
|
+
path.join(os.homedir(), '.agentvibes', 'bin', 'ffmpeg'),
|
|
108
|
+
'/usr/bin/ffmpeg',
|
|
109
|
+
'/usr/local/bin/ffmpeg',
|
|
110
|
+
],
|
|
111
|
+
'linux-arm64': () => [
|
|
112
|
+
path.join(os.homedir(), '.agentvibes', 'bin', 'ffmpeg'),
|
|
113
|
+
'/usr/bin/ffmpeg',
|
|
114
|
+
'/usr/local/bin/ffmpeg',
|
|
115
|
+
],
|
|
116
|
+
'win32-x64': () => {
|
|
117
|
+
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
118
|
+
const localappdata = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
119
|
+
const programfiles = process.env.PROGRAMFILES || path.join('C:', 'Program Files');
|
|
120
|
+
return [
|
|
121
|
+
path.join(appdata, 'AgentVibes', 'bin', 'ffmpeg.exe'),
|
|
122
|
+
path.join(localappdata, 'AgentVibes', 'bin', 'ffmpeg.exe'),
|
|
123
|
+
path.join(programfiles, 'ffmpeg', 'bin', 'ffmpeg.exe'),
|
|
124
|
+
];
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Derive ffprobe hints by substituting the binary name in every ffmpeg path.
|
|
129
|
+
// ffprobe is always co-located with ffmpeg so sharing the directory list is correct.
|
|
130
|
+
function deriveBinaryHints(templateHints, binaryName) {
|
|
131
|
+
const result = {};
|
|
132
|
+
for (const [plat, fn] of Object.entries(templateHints)) {
|
|
133
|
+
result[plat] = () => fn().map(p => {
|
|
134
|
+
const dir = path.dirname(p);
|
|
135
|
+
const ext = path.extname(p);
|
|
136
|
+
return path.join(dir, binaryName + ext);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const FFPROBE_HINTS = deriveBinaryHints(FFMPEG_HINTS, 'ffprobe');
|
|
143
|
+
|
|
144
|
+
const BINARY_HINTS = { piper: PIPER_HINTS, ffmpeg: FFMPEG_HINTS, ffprobe: FFPROBE_HINTS };
|
|
145
|
+
|
|
146
|
+
/** Canonical env var names — only override surface permitted by the contract */
|
|
147
|
+
export const ENV_VARS = {
|
|
148
|
+
piper: 'AGENTVIBES_PIPER_PATH',
|
|
149
|
+
ffmpeg: 'AGENTVIBES_FFMPEG_PATH',
|
|
150
|
+
ffprobe: 'AGENTVIBES_FFPROBE_PATH',
|
|
151
|
+
config_dir: 'AGENTVIBES_CONFIG_DIR',
|
|
152
|
+
data_dir: 'AGENTVIBES_DATA_DIR',
|
|
153
|
+
cache_dir: 'AGENTVIBES_CACHE_DIR',
|
|
154
|
+
voice_dir: 'AGENTVIBES_VOICE_DIR',
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ─── Binary Validation ────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate that a candidate binary path is a real, executable, working binary.
|
|
161
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
162
|
+
*/
|
|
163
|
+
export function validateBinary(binaryPath, binaryName) {
|
|
164
|
+
let stat;
|
|
165
|
+
try {
|
|
166
|
+
stat = fs.statSync(binaryPath);
|
|
167
|
+
} catch {
|
|
168
|
+
return { valid: false, reason: 'not_found' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!stat.isFile()) return { valid: false, reason: 'not_a_file' };
|
|
172
|
+
|
|
173
|
+
if (process.platform !== 'win32') {
|
|
174
|
+
try {
|
|
175
|
+
fs.accessSync(binaryPath, fs.constants.X_OK);
|
|
176
|
+
} catch {
|
|
177
|
+
return { valid: false, reason: 'not_executable' };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Smoke test: binary must respond to --version (or -version for ffmpeg/ffprobe)
|
|
182
|
+
const versionFlag = (binaryName === 'ffmpeg' || binaryName === 'ffprobe') ? '-version' : '--version';
|
|
183
|
+
try {
|
|
184
|
+
execFileSync(binaryPath, [versionFlag], { stdio: 'pipe', timeout: 3000 });
|
|
185
|
+
return { valid: true };
|
|
186
|
+
} catch {
|
|
187
|
+
return { valid: false, reason: 'version_check_failed' };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Find binary using which (POSIX) or where (Windows).
|
|
193
|
+
* Returns the realpath of the first result, or null.
|
|
194
|
+
*/
|
|
195
|
+
export function whichBinary(name) {
|
|
196
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
197
|
+
try {
|
|
198
|
+
const result = execFileSync(cmd, [name], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
|
|
199
|
+
const first = result.trim().split('\n')[0].trim();
|
|
200
|
+
if (!first) return null;
|
|
201
|
+
// Resolve symlinks to get the real binary path
|
|
202
|
+
return fs.realpathSync(first);
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Binary Resolution (4-step contract) ─────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve a binary following the contract resolution order.
|
|
212
|
+
* Throws a structured error if resolution fails.
|
|
213
|
+
*
|
|
214
|
+
* @param {'piper'|'ffmpeg'|'ffprobe'} binaryName
|
|
215
|
+
* @returns {{ path: string, source: string }}
|
|
216
|
+
*/
|
|
217
|
+
export function resolveBinary(binaryName) {
|
|
218
|
+
const platformId = detectPlatform();
|
|
219
|
+
const envVar = ENV_VARS[binaryName];
|
|
220
|
+
const tried = [];
|
|
221
|
+
|
|
222
|
+
// Step 1: ENV_OVERRIDE — if set, it's absolute. Invalid = hard fail, no fallthrough.
|
|
223
|
+
if (envVar && process.env[envVar]) {
|
|
224
|
+
const overridePath = process.env[envVar];
|
|
225
|
+
const validation = validateBinary(overridePath, binaryName);
|
|
226
|
+
tried.push({ step: 'ENV_OVERRIDE', path: overridePath, ...validation });
|
|
227
|
+
if (!validation.valid) {
|
|
228
|
+
const err = new Error(
|
|
229
|
+
`[AgentVibes] RESOLUTION_FAILURE\n` +
|
|
230
|
+
` binary: ${binaryName}\n` +
|
|
231
|
+
` error: ENV_OVERRIDE_INVALID\n` +
|
|
232
|
+
` platform: ${platformId}\n` +
|
|
233
|
+
` ${envVar}=${overridePath} (${validation.reason})\n` +
|
|
234
|
+
` fix: Correct the ${envVar} environment variable or unset it`
|
|
235
|
+
);
|
|
236
|
+
err.code = 'ENV_OVERRIDE_INVALID';
|
|
237
|
+
err.tried = tried;
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
return { path: overridePath, source: 'env_override' };
|
|
241
|
+
}
|
|
242
|
+
tried.push({ step: 'ENV_OVERRIDE', path: 'not set' });
|
|
243
|
+
|
|
244
|
+
// Step 2: which/where — respect what the user already has configured
|
|
245
|
+
const whichResult = whichBinary(binaryName);
|
|
246
|
+
if (whichResult) {
|
|
247
|
+
const validation = validateBinary(whichResult, binaryName);
|
|
248
|
+
tried.push({ step: 'WHICH', path: whichResult, ...validation });
|
|
249
|
+
if (validation.valid) {
|
|
250
|
+
return { path: whichResult, source: 'which' };
|
|
251
|
+
}
|
|
252
|
+
// Exists in PATH but failed validation — log and continue to hints
|
|
253
|
+
} else {
|
|
254
|
+
tried.push({ step: 'WHICH', path: 'not found' });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Step 3: Platform hint list — last resort before failure
|
|
258
|
+
const hintFn = BINARY_HINTS[binaryName]?.[platformId];
|
|
259
|
+
if (hintFn) {
|
|
260
|
+
const hints = hintFn();
|
|
261
|
+
for (let i = 0; i < hints.length; i++) {
|
|
262
|
+
const hintPath = hints[i];
|
|
263
|
+
const validation = validateBinary(hintPath, binaryName);
|
|
264
|
+
tried.push({ step: `HINT[${i}]`, path: hintPath, ...validation });
|
|
265
|
+
if (validation.valid) {
|
|
266
|
+
return { path: hintPath, source: `hint[${i}]` };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Step 4: FAIL — structured error with full audit trail
|
|
272
|
+
const triedFormatted = tried
|
|
273
|
+
.map(t => ` - ${t.step.padEnd(12)}: ${t.path}${t.reason ? ` → ${t.reason}` : ''}`)
|
|
274
|
+
.join('\n');
|
|
275
|
+
const err = new Error(
|
|
276
|
+
`[AgentVibes] RESOLUTION_FAILURE\n` +
|
|
277
|
+
` binary: ${binaryName}\n` +
|
|
278
|
+
` error: BINARY_NOT_FOUND\n` +
|
|
279
|
+
` platform: ${platformId}\n` +
|
|
280
|
+
` tried:\n${triedFormatted}\n` +
|
|
281
|
+
` fix: Install ${binaryName} or set ${envVar} to the binary path`
|
|
282
|
+
);
|
|
283
|
+
err.code = 'BINARY_NOT_FOUND';
|
|
284
|
+
err.binary = binaryName;
|
|
285
|
+
err.platform = platformId;
|
|
286
|
+
err.tried = tried;
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Directory Resolution ─────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Resolve the voice model directory (never contains tilde on return).
|
|
294
|
+
* Windows: %LOCALAPPDATA%\AgentVibes\voices
|
|
295
|
+
* POSIX: $XDG_DATA_HOME/agentvibes/voices or ~/.local/share/agentvibes/voices
|
|
296
|
+
*/
|
|
297
|
+
export function resolveVoiceDir() {
|
|
298
|
+
const override = process.env[ENV_VARS.voice_dir];
|
|
299
|
+
if (override) return path.resolve(override);
|
|
300
|
+
return path.join(resolveDataDir(), 'voices');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Resolve the data directory.
|
|
305
|
+
* Uses LOCALAPPDATA on Windows, XDG_DATA_HOME or ~/.local/share on POSIX.
|
|
306
|
+
*/
|
|
307
|
+
export function resolveDataDir() {
|
|
308
|
+
const override = process.env[ENV_VARS.data_dir];
|
|
309
|
+
if (override) return path.resolve(override);
|
|
310
|
+
|
|
311
|
+
const platformId = detectPlatform();
|
|
312
|
+
if (platformId === 'win32-x64') {
|
|
313
|
+
const localappdata = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
314
|
+
return path.join(localappdata, 'AgentVibes');
|
|
315
|
+
}
|
|
316
|
+
const xdgData = process.env.XDG_DATA_HOME;
|
|
317
|
+
if (xdgData) return path.join(xdgData, 'agentvibes');
|
|
318
|
+
return path.join(os.homedir(), '.local', 'share', 'agentvibes');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Resolve the config directory.
|
|
323
|
+
* Uses APPDATA on Windows, XDG_CONFIG_HOME or ~/.config on POSIX.
|
|
324
|
+
*/
|
|
325
|
+
export function resolveConfigDir() {
|
|
326
|
+
const override = process.env[ENV_VARS.config_dir];
|
|
327
|
+
if (override) return path.resolve(override);
|
|
328
|
+
|
|
329
|
+
const platformId = detectPlatform();
|
|
330
|
+
if (platformId === 'win32-x64') {
|
|
331
|
+
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
332
|
+
return path.join(appdata, 'AgentVibes');
|
|
333
|
+
}
|
|
334
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
|
335
|
+
if (xdgConfig) return path.join(xdgConfig, 'agentvibes');
|
|
336
|
+
return path.join(os.homedir(), '.config', 'agentvibes');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─── PATH Augmentation Helper ─────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Return extra PATH directories for the current platform.
|
|
343
|
+
* MCP servers launched by Claude Desktop inherit a sanitized PATH that omits
|
|
344
|
+
* Homebrew (Mac) and pipx (POSIX) locations — this list covers those gaps.
|
|
345
|
+
* Never includes directories already on PATH.
|
|
346
|
+
* @returns {string[]} List of absolute directory paths
|
|
347
|
+
*/
|
|
348
|
+
export function getPathAugmentation() {
|
|
349
|
+
const platformId = detectPlatform();
|
|
350
|
+
const extra = [];
|
|
351
|
+
|
|
352
|
+
if (platformId === 'darwin-arm64') {
|
|
353
|
+
extra.push('/opt/homebrew/bin', '/usr/local/bin');
|
|
354
|
+
} else if (platformId === 'darwin-x64') {
|
|
355
|
+
extra.push('/usr/local/bin', '/opt/homebrew/bin');
|
|
356
|
+
}
|
|
357
|
+
// Linux/WSL: ~/.local/bin is the only reliable extra location
|
|
358
|
+
if (platformId === 'linux-x64' || platformId === 'linux-arm64') {
|
|
359
|
+
extra.push(path.join(os.homedir(), '.local', 'bin'));
|
|
360
|
+
}
|
|
361
|
+
// pipx venv — all POSIX platforms
|
|
362
|
+
if (platformId !== 'win32-x64') {
|
|
363
|
+
extra.push(path.join(os.homedir(), '.local', 'share', 'pipx', 'venvs', 'piper-tts', 'bin'));
|
|
364
|
+
extra.push(path.join(os.homedir(), '.local', 'bin'));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Deduplicate, preserving order
|
|
368
|
+
return [...new Set(extra)];
|
|
369
|
+
}
|