agentvibes 3.5.9 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.agentvibes/bmad/bmad-voices-enabled.flag +0 -0
  2. package/.agentvibes/bmad/bmad-voices.md +69 -0
  3. package/.claude/config/audio-effects.cfg +1 -1
  4. package/.claude/config/background-music-position.txt +1 -27
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/audio-processor.sh +32 -17
  7. package/.claude/hooks/bmad-speak-enhanced.sh +5 -5
  8. package/.claude/hooks/bmad-speak.sh +4 -4
  9. package/.claude/hooks/bmad-voice-manager.sh +8 -8
  10. package/.claude/hooks/clawdbot-receiver-SECURE.sh +23 -25
  11. package/.claude/hooks/clawdbot-receiver.sh +28 -4
  12. package/.claude/hooks/language-manager.sh +1 -1
  13. package/.claude/hooks/path-resolver.sh +60 -0
  14. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -0
  15. package/.claude/hooks/play-tts-piper.sh +82 -24
  16. package/.claude/hooks/play-tts-ssh-remote.sh +13 -15
  17. package/.claude/hooks/play-tts.sh +16 -5
  18. package/.claude/hooks/session-start-tts.sh +26 -56
  19. package/.claude/hooks/soprano-gradio-synth.py +1 -1
  20. package/.claude/hooks/verbosity-manager.sh +10 -4
  21. package/.claude/settings.json +1 -1
  22. package/CLAUDE.md +129 -104
  23. package/README.md +418 -10
  24. package/RELEASE_NOTES.md +60 -1036
  25. package/bin/agentvibes-voice-browser.js +1827 -0
  26. package/bin/agentvibes.js +100 -0
  27. package/mcp-server/server.py +67 -3
  28. package/package.json +11 -2
  29. package/src/console/app.js +806 -0
  30. package/src/console/audio-env.js +123 -0
  31. package/src/console/brand-colors.js +13 -0
  32. package/src/console/footer-config.js +42 -0
  33. package/src/console/modals/.gitkeep +0 -0
  34. package/src/console/modals/modal-overlay.js +247 -0
  35. package/src/console/navigation.js +60 -0
  36. package/src/console/tabs/.gitkeep +0 -0
  37. package/src/console/tabs/agents-tab.js +369 -0
  38. package/src/console/tabs/help-tab.js +261 -0
  39. package/src/console/tabs/install-tab.js +990 -0
  40. package/src/console/tabs/music-tab.js +997 -0
  41. package/src/console/tabs/placeholder-tab.js +45 -0
  42. package/src/console/tabs/readme-tab.js +267 -0
  43. package/src/console/tabs/settings-tab.js +3949 -0
  44. package/src/console/tabs/voices-tab.js +1574 -0
  45. package/src/installer/music-file-input.js +304 -0
  46. package/src/installer.js +1353 -676
  47. package/src/services/.gitkeep +0 -0
  48. package/src/services/agent-voice-store.js +163 -0
  49. package/src/services/config-service.js +240 -0
  50. package/src/services/navigation-service.js +123 -0
  51. package/src/services/provider-service.js +132 -0
  52. package/src/services/verbosity-service.js +157 -0
  53. package/src/utils/audio-duration-validator.js +298 -0
  54. package/src/utils/audio-format-validator.js +277 -0
  55. package/src/utils/dependency-checker.js +3 -3
  56. package/src/utils/file-ownership-verifier.js +358 -0
  57. package/src/utils/music-file-validator.js +275 -0
  58. package/src/utils/preview-list-prompt.js +136 -0
  59. package/src/utils/provider-validator.js +144 -132
  60. package/src/utils/secure-music-storage.js +412 -0
  61. package/templates/agentvibes-receiver.sh +11 -7
  62. package/voice-assignments.json +8245 -0
  63. package/.claude/config/background-music-volume.txt +0 -1
  64. package/.claude/config/background-music.cfg +0 -1
  65. package/.claude/config/background-music.txt +0 -1
  66. package/.claude/config/tts-speech-rate.txt +0 -1
  67. package/.claude/config/tts-verbosity.txt +0 -1
  68. package/.claude/hooks/bmad-party-manager.sh +0 -225
  69. package/.claude/hooks/stop.sh +0 -38
  70. package/.claude/piper-voices-dir.txt +0 -1
  71. package/.mcp.json +0 -34
package/src/installer.js CHANGED
@@ -45,9 +45,12 @@ import { program } from 'commander';
45
45
  import path from 'node:path';
46
46
  import fs from 'node:fs/promises';
47
47
  import fsSync from 'node:fs';
48
- import { execSync, execFileSync, spawn } from 'node:child_process';
48
+ import { execSync, execFileSync, spawn, spawnSync } from 'node:child_process';
49
+ import os from 'node:os';
50
+ import crypto from 'node:crypto';
49
51
  import chalk from 'chalk';
50
52
  import inquirer from 'inquirer';
53
+ import search from '@inquirer/search';
51
54
  import figlet from 'figlet';
52
55
  import { detectBMAD } from './bmad-detector.js';
53
56
  import boxen from 'boxen';
@@ -67,6 +70,8 @@ import {
67
70
  getProviderDisplayName,
68
71
  attemptProviderInstallation,
69
72
  } from './utils/provider-validator.js';
73
+ import { promptForCustomMusic } from './installer/music-file-input.js';
74
+ import { createPreviewListPrompt } from './utils/preview-list-prompt.js';
70
75
 
71
76
  const __filename = fileURLToPath(import.meta.url);
72
77
  const __dirname = path.dirname(__filename);
@@ -77,6 +82,31 @@ const packageJson = JSON.parse(
77
82
  );
78
83
  const VERSION = packageJson.version;
79
84
 
85
+ // Personality emoji mapping for quick visual recognition
86
+ const personalityEmojis = {
87
+ 'angry': '😠',
88
+ 'annoying': '😤',
89
+ 'crass': '🤬',
90
+ 'dramatic': 'šŸŽ­',
91
+ 'dry-humor': '😐',
92
+ 'flirty': '😘',
93
+ 'funny': 'šŸ˜‚',
94
+ 'grandpa': 'šŸ‘“',
95
+ 'millennial': 'šŸ™„',
96
+ 'moody': 'šŸ˜’',
97
+ 'none': '😊',
98
+ 'normal': '😊',
99
+ 'pirate': 'šŸ“ā€ā˜ ļø',
100
+ 'poetic': 'šŸ“œ',
101
+ 'professional': 'šŸ‘”',
102
+ 'rapper': 'šŸŽ¤',
103
+ 'robot': 'šŸ¤–',
104
+ 'sarcastic': 'šŸ˜',
105
+ 'sassy': 'šŸ’',
106
+ 'surfer-dude': 'šŸ„',
107
+ 'zen': '🧘'
108
+ };
109
+
80
110
  // Validate Node.js executable is available (CLAUDE.md - early validation)
81
111
  if (!process.execPath) {
82
112
  console.error('āŒ Error: Node.js executable path not found');
@@ -122,15 +152,244 @@ function hasPulseAudioTunnel() {
122
152
  process.env.PULSE_SERVER.toLowerCase().startsWith('tcp:');
123
153
  }
124
154
 
155
+ /**
156
+ * Story 2.3: Detect terminal emoji support
157
+ * Checks $TERM, locale, and platform to determine emoji capability
158
+ * @returns {boolean} True if terminal supports emoji
159
+ */
160
+ function supportsEmoji() {
161
+ // Check TERM environment variable
162
+ const term = process.env.TERM || '';
163
+ const lang = process.env.LANG || '';
164
+ const lcAll = process.env.LC_ALL || '';
165
+
166
+ // Explicitly unsupported terminals
167
+ const unsupportedTerminals = ['dumb', 'emacs', 'ansi'];
168
+ if (unsupportedTerminals.includes(term.toLowerCase())) {
169
+ return false;
170
+ }
171
+
172
+ // Check for UTF-8 locale (required for emoji)
173
+ const isUtf8 = lang.includes('utf8') || lang.includes('UTF-8') ||
174
+ lcAll.includes('utf8') || lcAll.includes('UTF-8');
175
+
176
+ // Modern terminals (check common ones)
177
+ const modernTerminals = [
178
+ 'xterm-256color', 'screen-256color', 'tmux-256color',
179
+ 'iterm2', 'iterm', 'vscode', 'alacritty', 'kitty',
180
+ 'wezterm', 'windows-terminal', 'conemu'
181
+ ];
182
+
183
+ const isModernTerminal = modernTerminals.some(t => term.toLowerCase().includes(t));
184
+
185
+ // Windows Terminal always supports emoji
186
+ const isWindowsTerminal = process.platform === 'win32' &&
187
+ (process.env.WT_SESSION || process.env.WT_PROFILE_ID);
188
+
189
+ // macOS Terminal and iTerm2
190
+ const isMacOS = process.platform === 'darwin';
191
+
192
+ // Linux with proper UTF-8
193
+ const isLinuxWithUtf8 = process.platform === 'linux' && isUtf8;
194
+
195
+ // Unknown terminal with UTF-8: Only enable emoji if TERM is not explicitly unsupported AND has UTF-8
196
+ // This prevents false positives like "vt100" with UTF-8 reporting emoji support
197
+ const unknownTerminalWithUtf8 = term &&
198
+ !unsupportedTerminals.includes(term.toLowerCase()) &&
199
+ isUtf8;
200
+
201
+ // Default to true for: modern terms, Windows Terminal, macOS, Linux with UTF-8, or unknown term with UTF-8
202
+ // Default to false for: dumb/emacs/ansi terminals or environments without UTF-8
203
+ return isModernTerminal || isWindowsTerminal || isMacOS || isLinuxWithUtf8 || unknownTerminalWithUtf8;
204
+ }
205
+
206
+ /**
207
+ * Story 2.4: Get personality display with emoji or text fallback
208
+ * Returns emoji if supported, otherwise returns text label like "[personality]"
209
+ * @param {string} personality - Personality name
210
+ * @param {boolean} emojiSupported - Pre-computed emoji support (avoids redundant env var reads)
211
+ * @returns {string} Either emoji or "[personality-name]" fallback
212
+ */
213
+ function getPersonalityIcon(personality, emojiSupported) {
214
+ const emoji = personalityEmojis[personality] || '✨';
215
+
216
+ // Use provided emojiSupported to avoid recalculating for every personality (performance)
217
+ if (emojiSupported) {
218
+ return emoji;
219
+ }
220
+
221
+ // Text fallback for unsupported terminals: [personality-name]
222
+ return `[${personality}]`;
223
+ }
224
+
225
+ /**
226
+ * Check if Piper TTS is installed
227
+ * @returns {boolean} True if piper command exists
228
+ */
229
+ function isPiperInstalled() {
230
+ try {
231
+ execSync('which piper', {
232
+ stdio: 'pipe',
233
+ timeout: 3000
234
+ });
235
+ return true;
236
+ } catch (e) {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Check if Soprano TTS is installed
243
+ * @returns {boolean} True if soprano-tts or soprano-webui command exists
244
+ */
245
+ function isSopranoInstalled() {
246
+ try {
247
+ execSync('which soprano-tts || which soprano-webui', {
248
+ stdio: 'pipe',
249
+ timeout: 3000
250
+ });
251
+ return true;
252
+ } catch (e) {
253
+ return false;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Play voice sample for preview during voice selection
259
+ * @param {string} voiceName - Name of the voice (e.g., 'en_US-lessac-medium', 'soprano-default')
260
+ * @param {string} provider - TTS provider ('piper' or 'soprano')
261
+ * @returns {Promise<boolean>} True if sample played successfully
262
+ */
263
+ async function playVoiceSample(voiceName, provider) {
264
+ try {
265
+ const samplesDir = path.join(__dirname, '..', '.claude', 'audio', 'voice-samples', provider);
266
+
267
+ // Try friendly name first (e.g., "ryan.wav")
268
+ let sampleFile = path.join(samplesDir, `${voiceName}.wav`);
269
+
270
+ // If not found and looks like a Piper ID, try that too
271
+ if (!fsSync.existsSync(sampleFile) && voiceName.includes('-')) {
272
+ sampleFile = path.join(samplesDir, `${voiceName}.wav`);
273
+ }
274
+
275
+ // Check if pre-recorded sample exists
276
+ if (fsSync.existsSync(sampleFile)) {
277
+ console.log(chalk.cyan(' šŸ”Š Playing voice sample...'));
278
+
279
+ // Play using sox/aplay - use spawn for non-blocking playback
280
+ try {
281
+ // Play using aplay directly (no shell interpolation — prevents command injection)
282
+ const player = spawn('aplay', [sampleFile], {
283
+ detached: false,
284
+ stdio: 'ignore'
285
+ });
286
+
287
+ // Return the process so it can be killed
288
+ return player;
289
+ } catch (e) {
290
+ // Fallback: generate on-the-fly if provider is available
291
+ }
292
+ }
293
+
294
+ // Generate sample on-the-fly if provider is running
295
+ if (provider === 'piper' && isPiperInstalled()) {
296
+ const text = `Hi, I'm ${voiceName.split('-')[1] || 'Piper'}`;
297
+ // Use bash -c with positional args to prevent command injection via text/voiceName
298
+ spawnSync('bash', ['-c', 'echo "$1" | piper --model "$2" --output_raw | aplay -r 22050 -f S16_LE -t raw -', '_', text, voiceName], {
299
+ stdio: 'inherit',
300
+ timeout: 15000
301
+ });
302
+ return true;
303
+ } else if (provider === 'soprano' && await isSopranoRunning()) {
304
+ // Generate via Soprano API
305
+ const text = "Hi, I'm Soprano";
306
+ const response = await fetch('http://localhost:7860/api/tts', {
307
+ method: 'POST',
308
+ headers: { 'Content-Type': 'application/json' },
309
+ body: JSON.stringify({ text, voice: 'soprano-default' }),
310
+ signal: AbortSignal.timeout(5000)
311
+ });
312
+
313
+ if (response.ok) {
314
+ const audio = await response.arrayBuffer();
315
+ // Save temporarily and play
316
+ const tempFile = path.join(os.tmpdir(), `soprano-sample-${crypto.randomBytes(8).toString('hex')}.wav`);
317
+ fsSync.writeFileSync(tempFile, Buffer.from(audio));
318
+ try {
319
+ // Use spawnSync with argument array to prevent command injection
320
+ spawnSync('aplay', [tempFile], { stdio: 'pipe', timeout: 5000 });
321
+ } finally {
322
+ fsSync.unlinkSync(tempFile);
323
+ }
324
+ return true;
325
+ }
326
+ }
327
+
328
+ return false;
329
+ } catch (e) {
330
+ console.log(chalk.gray(' (Preview not available)'));
331
+ return false;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Check if Soprano server is running on port 7860
337
+ * @returns {Promise<boolean>} True if server responds to health check
338
+ */
339
+ async function isSopranoRunning() {
340
+ try {
341
+ const response = await fetch('http://localhost:7860/health', {
342
+ signal: AbortSignal.timeout(2000)
343
+ });
344
+ return response.ok;
345
+ } catch (e) {
346
+ return false;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Start Soprano TTS server in background
352
+ * @returns {Promise<boolean>} True if successfully started
353
+ */
354
+ async function startSopranoServer() {
355
+ try {
356
+ console.log(chalk.gray('šŸš€ Starting Soprano TTS server...'));
357
+
358
+ // Start soprano-webui in background
359
+ const sopranoProcess = spawn('soprano-webui', ['--port', '7860'], {
360
+ detached: true,
361
+ stdio: 'ignore'
362
+ });
363
+
364
+ sopranoProcess.unref(); // Allow parent to exit independently
365
+
366
+ // Wait up to 10 seconds for server to be ready
367
+ for (let i = 0; i < 20; i++) {
368
+ await new Promise(resolve => setTimeout(resolve, 500));
369
+ if (await isSopranoRunning()) {
370
+ console.log(chalk.green('āœ“ Soprano TTS server started successfully\n'));
371
+ return true;
372
+ }
373
+ }
374
+
375
+ console.log(chalk.yellow('āš ļø Soprano server started but not responding yet\n'));
376
+ return false;
377
+ } catch (e) {
378
+ console.log(chalk.yellow('āš ļø Failed to start Soprano server:', e.message, '\n'));
379
+ return false;
380
+ }
381
+ }
382
+
125
383
  /**
126
384
  * Detect system capabilities for smart provider recommendations
127
- * @returns {Promise<Object>} System info including GPU, memory, platform
385
+ * @returns {Promise<Object>} System info including GPU, memory, platform, Soprano availability
128
386
  */
129
387
  async function detectSystemCapabilities() {
130
388
  const isMacOS = process.platform === 'darwin';
131
389
  const isAndroid = isTermux();
132
390
  let hasGPU = false;
133
391
  let totalRAM = 0;
392
+ let sopranoAvailable = false;
134
393
 
135
394
  try {
136
395
  // Detect NVIDIA GPU
@@ -183,12 +442,24 @@ async function detectSystemCapabilities() {
183
442
  totalRAM = 4096;
184
443
  }
185
444
 
445
+ // Detect and auto-start Soprano if installed
446
+ if (isSopranoInstalled()) {
447
+ const isRunning = await isSopranoRunning();
448
+ if (isRunning) {
449
+ sopranoAvailable = true;
450
+ } else {
451
+ // Soprano installed but not running - try to start it
452
+ sopranoAvailable = await startSopranoServer();
453
+ }
454
+ }
455
+
186
456
  return {
187
457
  hasGPU,
188
458
  lowMemory: totalRAM < 4096,
189
459
  totalRAM,
190
460
  isMacOS,
191
- isAndroid
461
+ isAndroid,
462
+ sopranoAvailable
192
463
  };
193
464
  }
194
465
 
@@ -397,7 +668,8 @@ async function showPaginatedContent(pages, options = {}) {
397
668
  const { action } = await inquirer.prompt([{
398
669
  type: 'list',
399
670
  name: 'action',
400
- message: '', // Hide the "Use arrow keys" message
671
+ message: chalk.cyan('šŸ’” Try these AgentVibes commands in Claude Code terminal'),
672
+ prefix: '',
401
673
  choices,
402
674
  default: currentPage < pages.length - 1 ? 'next' : 'continue'
403
675
  }]);
@@ -425,11 +697,12 @@ async function showPaginatedContent(pages, options = {}) {
425
697
  function getPageTitle(pageNum) {
426
698
  const titles = {
427
699
  0: 'šŸ”§ System Dependencies',
428
- 1: 'šŸŽ™ļø TTS Provider Configuration',
700
+ 1: 'šŸ”Œ TTS Provider Configuration',
429
701
  2: 'šŸŽ¤ Voice Selection',
430
702
  3: 'šŸ˜Ž Personality Selection',
431
- 4: 'šŸ’§ Audio Settings',
432
- 5: 'šŸ”Š Verbosity Settings'
703
+ 4: 'šŸŽ›ļø Reverb Settings',
704
+ 5: 'šŸŽµ Background Music',
705
+ 6: 'šŸ”Š Verbosity Settings'
433
706
  };
434
707
  return titles[pageNum] || 'Configuration';
435
708
  }
@@ -442,8 +715,8 @@ async function handleSystemDependenciesPage() {
442
715
  const { checkDependencies, getInstallCommands } = await import('./utils/dependency-checker.js');
443
716
  const depResults = checkDependencies();
444
717
 
445
- let depContent = chalk.gray('System dependencies are tools AgentVibes needs to function properly.\n');
446
- depContent += chalk.gray('Required tools must be installed, optional tools enable extra features.\n\n');
718
+ let depContent = chalk.gray('System dependencies detected and already installed.\n');
719
+ depContent += chalk.gray('These tools enable AgentVibes features and functionality.\n\n');
447
720
 
448
721
  // Satisfied dependencies
449
722
  if (depResults.core.node?.isCompatible) {
@@ -477,6 +750,39 @@ async function handleSystemDependenciesPage() {
477
750
  depContent += chalk.green('āœ“ audio player (paplay/aplay/mpv)\n');
478
751
  }
479
752
 
753
+ // Check TTS providers
754
+ const piperInstalled = isPiperInstalled();
755
+ const sopranoInstalled = isSopranoInstalled();
756
+
757
+ if (piperInstalled || sopranoInstalled) {
758
+ depContent += '\n' + chalk.gray('─'.repeat(50)) + '\n\n';
759
+ depContent += chalk.cyan.bold('TTS Providers Already Installed:\n\n');
760
+
761
+ if (piperInstalled) {
762
+ try {
763
+ const piperPath = execSync('which piper 2>/dev/null', { encoding: 'utf8' }).trim();
764
+ depContent += chalk.green('āœ“ Piper TTS (offline voice synthesis)\n');
765
+ depContent += chalk.gray(` ${piperPath}\n`);
766
+ } catch (e) {
767
+ depContent += chalk.green('āœ“ Piper TTS (offline voice synthesis)\n');
768
+ }
769
+ }
770
+ if (sopranoInstalled) {
771
+ try {
772
+ let sopranoPath = '';
773
+ try {
774
+ sopranoPath = execSync('which soprano-tts 2>/dev/null', { encoding: 'utf8' }).trim();
775
+ } catch (e) {
776
+ sopranoPath = execSync('which soprano-webui 2>/dev/null', { encoding: 'utf8' }).trim();
777
+ }
778
+ depContent += chalk.green('āœ“ Soprano TTS (premium quality)\n');
779
+ depContent += chalk.gray(` ${sopranoPath}\n`);
780
+ } catch (e) {
781
+ depContent += chalk.green('āœ“ Soprano TTS (premium quality)\n');
782
+ }
783
+ }
784
+ }
785
+
480
786
  // Missing dependencies
481
787
  if (Object.keys(depResults.missing).length > 0) {
482
788
  depContent += '\n' + chalk.gray('─'.repeat(50)) + '\n\n';
@@ -492,8 +798,7 @@ async function handleSystemDependenciesPage() {
492
798
 
493
799
  depContent += '\n' + chalk.gray('TTS will still work without optional tools');
494
800
 
495
- // Add install commands
496
- const os = await import('os');
801
+ // Add install commands (os imported at top level)
497
802
  const platform = os.platform();
498
803
  const installCmds = getInstallCommands(depResults.missing, platform);
499
804
 
@@ -517,6 +822,185 @@ async function handleSystemDependenciesPage() {
517
822
  });
518
823
 
519
824
  console.log(depsBoxen);
825
+
826
+ // Return status for navigation message
827
+ return {
828
+ allMet: Object.keys(depResults.missing).length === 0,
829
+ missingCount: Object.keys(depResults.missing).length
830
+ };
831
+ }
832
+
833
+ /**
834
+ * Validate and copy custom music track to .claude/audio/tracks directory
835
+ * @param {string} userFilePath - Path provided by user
836
+ * @param {string} tracksDir - Target directory for audio tracks
837
+ * @returns {Promise<string|null>} Filename if successful, null if cancelled
838
+ */
839
+ async function handleCustomMusicTrack(userFilePath, tracksDir) {
840
+ try {
841
+ // Validate file exists and resolve path securely
842
+ const resolvedPath = path.resolve(userFilePath.trim());
843
+
844
+ if (!fsSync.existsSync(resolvedPath)) {
845
+ console.error(chalk.red('āœ— File not found. Please check the path.'));
846
+ return null;
847
+ }
848
+
849
+ // Validate file extension (whitelist approach per CLAUDE.md)
850
+ const ext = path.extname(resolvedPath).toLowerCase();
851
+ const supportedFormats = ['.mp3', '.wav', '.ogg', '.m4a'];
852
+ if (!supportedFormats.includes(ext)) {
853
+ console.error(chalk.red('āœ— Unsupported format. Use: .mp3, .wav, .ogg, or .m4a'));
854
+ return null;
855
+ }
856
+
857
+ // Verify file is within expected directory (prevent path traversal)
858
+ if (!resolvedPath.startsWith(path.resolve(os.homedir()))) {
859
+ console.error(chalk.red('āœ— File must be in your home directory or subdirectories.'));
860
+ return null;
861
+ }
862
+
863
+ // Get original filename and sanitize it
864
+ let originalFilename = path.basename(resolvedPath);
865
+ const sanitizedFilename = originalFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
866
+
867
+ // Create tracks directory if needed
868
+ await fs.mkdir(tracksDir, { recursive: true });
869
+
870
+ // Copy file to tracks directory
871
+ const destPath = path.join(tracksDir, sanitizedFilename);
872
+ await fs.copyFile(resolvedPath, destPath);
873
+
874
+ return sanitizedFilename;
875
+ } catch (err) {
876
+ console.error(chalk.red(`āœ— Error: ${err.message}`));
877
+ return null;
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Load custom tracks from global registry
883
+ * @returns {Promise<Array>} Array of custom track objects {name, filename}
884
+ */
885
+ async function loadCustomTracks() {
886
+ try {
887
+ const registryPath = path.join(process.env.HOME || process.env.USERPROFILE, '.agentvibes', 'custom-tracks.json');
888
+ if (fsSync.existsSync(registryPath)) {
889
+ const content = await fs.readFile(registryPath, 'utf-8');
890
+ return JSON.parse(content);
891
+ }
892
+ } catch (err) {
893
+ // Silently fail - registry may not exist yet
894
+ }
895
+ return [];
896
+ }
897
+
898
+ /**
899
+ * Save custom tracks to global registry
900
+ * @param {Array} tracks - Array of custom track objects
901
+ */
902
+ async function saveCustomTracks(tracks) {
903
+ try {
904
+ const registryDir = path.join(process.env.HOME || process.env.USERPROFILE, '.agentvibes');
905
+ await fs.mkdir(registryDir, { recursive: true });
906
+ const registryPath = path.join(registryDir, 'custom-tracks.json');
907
+ await fs.writeFile(registryPath, JSON.stringify(tracks, null, 2));
908
+ } catch (err) {
909
+ // Silently fail - non-critical
910
+ }
911
+ }
912
+
913
+ // Track currently playing audio preview to prevent overlaps
914
+ let currentAudioPreview = null;
915
+
916
+ /**
917
+ * Preview audio track using available audio player
918
+ * @param {string} trackName - Name of the track file to preview
919
+ * @param {string} tracksDir - Directory containing audio tracks
920
+ * @returns {Promise<boolean>} True if preview was attempted, false if no audio tools available
921
+ */
922
+ async function previewAudioTrack(trackName, tracksDir) {
923
+ // Stop any currently playing preview
924
+ if (currentAudioPreview && !currentAudioPreview.killed) {
925
+ currentAudioPreview.kill('SIGTERM');
926
+ currentAudioPreview = null;
927
+ }
928
+
929
+ const trackPath = path.join(tracksDir, trackName);
930
+
931
+ // Verify track exists
932
+ if (!fsSync.existsSync(trackPath)) {
933
+ console.log(chalk.yellow('āš ļø Track file not found'));
934
+ return false;
935
+ }
936
+
937
+ // Try available audio players in order of preference
938
+ const audioPlayers = ['ffplay', 'play', 'mpv'];
939
+ let playerAvailable = false;
940
+
941
+ for (const player of audioPlayers) {
942
+ try {
943
+ execSync(`which ${player}`, { stdio: 'ignore' });
944
+ playerAvailable = true;
945
+
946
+ console.log(chalk.cyan('ā–¶ Playing preview (10 seconds)...'));
947
+
948
+ // Build appropriate command for each player
949
+ let playerArgs = [];
950
+ if (player === 'ffplay') {
951
+ playerArgs = ['-nodisp', '-autoexit', '-t', '10', '-volume', '30', trackPath];
952
+ } else if (player === 'play') {
953
+ playerArgs = [trackPath, 'trim', '0', '10'];
954
+ } else if (player === 'mpv') {
955
+ playerArgs = ['--no-video', '--duration=10', '--volume=30', trackPath];
956
+ }
957
+
958
+ // Spawn player process (manual setTimeout below handles the safety kill)
959
+ const audioProcess = spawn(player, playerArgs, {
960
+ stdio: ['ignore', 'ignore', 'ignore']
961
+ });
962
+
963
+ // Store reference to current preview
964
+ currentAudioPreview = audioProcess;
965
+
966
+ // Handle process completion
967
+ return new Promise((resolve) => {
968
+ const timeoutHandle = setTimeout(() => {
969
+ if (audioProcess && !audioProcess.killed) {
970
+ audioProcess.kill('SIGTERM');
971
+ }
972
+ if (currentAudioPreview === audioProcess) {
973
+ currentAudioPreview = null;
974
+ }
975
+ resolve(true);
976
+ }, 11000); // 11 second timeout
977
+
978
+ audioProcess.on('close', () => {
979
+ clearTimeout(timeoutHandle);
980
+ if (currentAudioPreview === audioProcess) {
981
+ currentAudioPreview = null;
982
+ }
983
+ resolve(true);
984
+ });
985
+
986
+ audioProcess.on('error', () => {
987
+ clearTimeout(timeoutHandle);
988
+ if (currentAudioPreview === audioProcess) {
989
+ currentAudioPreview = null;
990
+ }
991
+ resolve(true);
992
+ });
993
+ });
994
+ } catch (err) {
995
+ // This player not available, try next
996
+ continue;
997
+ }
998
+ }
999
+
1000
+ if (!playerAvailable) {
1001
+ console.log(chalk.yellow('āš ļø Audio preview requires ffplay, sox (play), or mpv'));
1002
+ }
1003
+ return false;
520
1004
  }
521
1005
 
522
1006
  /**
@@ -530,6 +1014,7 @@ async function collectConfiguration(options = {}) {
530
1014
  piperPath: null,
531
1015
  sshHost: null,
532
1016
  defaultVoice: null,
1017
+ pretext: '',
533
1018
  personality: 'none',
534
1019
  reverb: 'light',
535
1020
  backgroundMusic: {
@@ -539,6 +1024,22 @@ async function collectConfiguration(options = {}) {
539
1024
  verbosity: 'high'
540
1025
  };
541
1026
 
1027
+ // Load existing pretext from file if it exists (Story 1.2: File Persistence)
1028
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
1029
+ const claudeDir = path.join(homeDir, '.claude');
1030
+ const pretextFile = path.join(claudeDir, 'config', 'tts-pretext.txt');
1031
+ try {
1032
+ if (fsSync.existsSync(pretextFile)) {
1033
+ const existingPretext = fsSync.readFileSync(pretextFile, 'utf-8').trim();
1034
+ if (existingPretext) {
1035
+ config.pretext = existingPretext;
1036
+ }
1037
+ }
1038
+ } catch (err) {
1039
+ // Gracefully handle read errors (file permissions, encoding issues, etc.)
1040
+ // Pretext will remain empty string if file can't be read
1041
+ }
1042
+
542
1043
  // Detect environment type
543
1044
  const environment = detectEnvironment();
544
1045
  const isAndroid = isTermux();
@@ -573,7 +1074,7 @@ async function collectConfiguration(options = {}) {
573
1074
  }
574
1075
 
575
1076
  let currentPage = 0;
576
- const sectionPages = 6; // System Dependencies, Provider, Voice Selection, Personality Selection, Audio Settings, Verbosity
1077
+ const sectionPages = 7; // System Dependencies, Provider, Voice Selection, Personality Selection, Reverb, Background Music, Verbosity
577
1078
  const pageOffset = options.pageOffset || 0;
578
1079
  const totalPages = options.totalPages || sectionPages;
579
1080
 
@@ -593,8 +1094,10 @@ async function collectConfiguration(options = {}) {
593
1094
  const { header, footer } = createPageHeaderFooter(pageTitle, currentPage, totalPages, pageOffset);
594
1095
  console.log(header);
595
1096
 
1097
+ let pageStatus = null; // Track page completion status for navigation message
1098
+
596
1099
  if (currentPage === 0) {
597
- await handleSystemDependenciesPage();
1100
+ pageStatus = await handleSystemDependenciesPage();
598
1101
  } else if (currentPage === 1) {
599
1102
  // Page 2: TTS Provider & Voice Storage
600
1103
 
@@ -880,6 +1383,57 @@ async function collectConfiguration(options = {}) {
880
1383
 
881
1384
  // Validate provider installation before accepting selection
882
1385
  console.log(chalk.gray(`\n Checking for ${getProviderDisplayName(provider)}...`));
1386
+
1387
+ // Special handling for Soprano - check if running, auto-start if installed
1388
+ if (provider === 'soprano') {
1389
+ const sopranoInstalled = isSopranoInstalled();
1390
+ const sopranoRunning = await isSopranoRunning();
1391
+
1392
+ if (sopranoInstalled && !sopranoRunning) {
1393
+ // Soprano installed but not running - offer to start it
1394
+ console.log(chalk.yellow('\nāš ļø Soprano TTS is installed but server is not running'));
1395
+
1396
+ const { startAction } = await inquirer.prompt([{
1397
+ type: 'list',
1398
+ name: 'startAction',
1399
+ message: 'What would you like to do?',
1400
+ choices: [
1401
+ { name: chalk.green('Start Soprano server now (recommended)'), value: 'start' },
1402
+ { name: 'I\'ll start it myself', value: 'manual' },
1403
+ { name: 'Choose a different provider', value: 'back' }
1404
+ ]
1405
+ }]);
1406
+
1407
+ if (startAction === 'start') {
1408
+ const started = await startSopranoServer();
1409
+ if (started) {
1410
+ config.provider = provider;
1411
+ providerSelected = true;
1412
+ continue;
1413
+ } else {
1414
+ console.log(chalk.yellow('\nāš ļø Failed to start Soprano automatically'));
1415
+ console.log(chalk.cyan(' Try starting manually:'));
1416
+ console.log(chalk.gray(' $ soprano-webui --port 7860\n'));
1417
+ continue;
1418
+ }
1419
+ } else if (startAction === 'manual') {
1420
+ console.log(chalk.cyan('\nšŸ“ To start Soprano manually, run:'));
1421
+ console.log(chalk.gray(' $ soprano-webui --port 7860\n'));
1422
+ continue;
1423
+ } else {
1424
+ // User chose to go back
1425
+ continue;
1426
+ }
1427
+ } else if (sopranoInstalled && sopranoRunning) {
1428
+ // Soprano installed and running - all good!
1429
+ console.log(chalk.green('\nāœ“ Soprano TTS detected and running!\n'));
1430
+ config.provider = provider;
1431
+ providerSelected = true;
1432
+ continue;
1433
+ }
1434
+ // If not installed, fall through to normal validation below
1435
+ }
1436
+
883
1437
  const validation = await validateProvider(provider);
884
1438
 
885
1439
  if (!validation.installed) {
@@ -956,6 +1510,9 @@ async function collectConfiguration(options = {}) {
956
1510
  console.log(chalk.green(`\nāœ“ ${displayName} Detected and selected!\n`));
957
1511
  config.provider = provider;
958
1512
  providerSelected = true; // Exit provider selection loop
1513
+
1514
+ // Auto-advance flag for navigation
1515
+ config._autoAdvance = true;
959
1516
  }
960
1517
  }
961
1518
 
@@ -1148,36 +1705,105 @@ async function collectConfiguration(options = {}) {
1148
1705
  ));
1149
1706
 
1150
1707
  if (config.provider === 'piper') {
1151
- // Piper voices - popular selections
1152
- const piperVoices = [
1153
- { name: chalk.cyan('en_US-ryan-high') + chalk.gray(' (Male, American, High Quality)'), value: 'en_US-ryan-high' },
1154
- { name: chalk.magenta('en_US-amy-medium') + chalk.gray(' (Female, American, Clear)'), value: 'en_US-amy-medium' },
1155
- { name: chalk.cyan('en_US-joe-medium') + chalk.gray(' (Male, American, Warm)'), value: 'en_US-joe-medium' },
1156
- { name: chalk.magenta('en_US-lessac-medium') + chalk.gray(' (Female, American, Professional)'), value: 'en_US-lessac-medium' },
1157
- { name: chalk.cyan('en_GB-alan-medium') + chalk.gray(' (Male, British, Refined)'), value: 'en_GB-alan-medium' },
1158
- { name: chalk.magenta('en_GB-southern_english_female-medium') + chalk.gray(' (Female, British)'), value: 'en_GB-southern_english_female-medium' },
1708
+ // Check if Piper is installed for voice previews
1709
+ const piperAvailable = isPiperInstalled();
1710
+
1711
+ // Load voice metadata for friendly names
1712
+ let voiceMetadata;
1713
+ try {
1714
+ const metadataPath = path.join(__dirname, '..', '.agentvibes', 'config', 'voice-metadata.json');
1715
+ voiceMetadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
1716
+ } catch (e) {
1717
+ voiceMetadata = null;
1718
+ }
1719
+
1720
+ // Build voice choices
1721
+ const previewHint = piperAvailable ? ' ' + chalk.gray('[SPACE to preview]') : '';
1722
+ let piperVoices;
1723
+
1724
+ if (voiceMetadata && voiceMetadata.installerVoices) {
1725
+ // Use voice metadata system - all 10 voices with friendly names
1726
+ piperVoices = voiceMetadata.installerVoices.map(friendlyName => {
1727
+ const voice = voiceMetadata.voices[friendlyName];
1728
+ const isMale = voice.gender === 'male';
1729
+ const color = isMale ? chalk.cyan : chalk.hex('#FF69B4'); // Lighter pink for females
1730
+
1731
+ return {
1732
+ name: color(voice.displayName) + chalk.gray(` (${voice.gender}, ${voice.accent}, ${voice.quality})`) + previewHint,
1733
+ value: friendlyName // Store friendly name
1734
+ };
1735
+ });
1736
+ } else {
1737
+ // Fallback to old hardcoded list
1738
+ piperVoices = [
1739
+ { name: chalk.cyan('en_US-ryan-high') + chalk.gray(' (Male, American, High Quality)') + previewHint, value: 'en_US-ryan-high' },
1740
+ { name: chalk.magenta('en_US-amy-medium') + chalk.gray(' (Female, American, Clear)') + previewHint, value: 'en_US-amy-medium' },
1741
+ { name: chalk.cyan('en_US-joe-medium') + chalk.gray(' (Male, American, Warm)') + previewHint, value: 'en_US-joe-medium' },
1742
+ { name: chalk.magenta('en_US-lessac-medium') + chalk.gray(' (Female, American, Professional)') + previewHint, value: 'en_US-lessac-medium' },
1743
+ { name: chalk.cyan('en_GB-alan-medium') + chalk.gray(' (Male, British, Refined)') + previewHint, value: 'en_GB-alan-medium' },
1744
+ { name: chalk.magenta('en_GB-southern_english_female-medium') + chalk.gray(' (Female, British)') + previewHint, value: 'en_GB-southern_english_female-medium' }
1745
+ ];
1746
+ }
1747
+
1748
+ piperVoices.push(
1159
1749
  new inquirer.Separator(),
1160
1750
  { name: chalk.yellow('Skip - I\'ll set this later'), value: '__skip__' },
1161
1751
  { name: chalk.magentaBright('← Back to Provider Selection'), value: '__back__' }
1162
- ];
1752
+ );
1163
1753
 
1164
- const { selectedVoice } = await inquirer.prompt([{
1165
- type: 'list',
1166
- name: 'selectedVoice',
1167
- message: chalk.yellow('Select your default Piper voice:'),
1168
- choices: piperVoices,
1169
- default: 'en_US-ryan-high',
1170
- pageSize: 12
1171
- }]);
1754
+ let selectedVoice;
1755
+ if (piperAvailable) {
1756
+ const result = await createPreviewListPrompt(inquirer, {
1757
+ name: 'selectedVoice',
1758
+ message: chalk.yellow('Select your default Piper voice:'),
1759
+ choices: piperVoices,
1760
+ default: 'en_US-ryan-high',
1761
+ pageSize: 12,
1762
+ onPreview: async (voiceName) => {
1763
+ await playVoiceSample(voiceName, 'piper');
1764
+ }
1765
+ });
1766
+ selectedVoice = result.selectedVoice;
1767
+ } else {
1768
+ const result = await inquirer.prompt([{
1769
+ type: 'list',
1770
+ name: 'selectedVoice',
1771
+ message: chalk.yellow('Select your default Piper voice:'),
1772
+ choices: piperVoices,
1773
+ default: 'en_US-ryan-high',
1774
+ pageSize: 12
1775
+ }]);
1776
+ selectedVoice = result.selectedVoice;
1777
+ }
1172
1778
 
1173
1779
  if (selectedVoice === '__back__') {
1174
1780
  return null;
1175
1781
  }
1176
1782
 
1177
1783
  if (selectedVoice !== '__skip__') {
1178
- config.defaultVoice = selectedVoice;
1784
+ // Convert friendly name to Piper ID if using metadata
1785
+ if (voiceMetadata && voiceMetadata.voices[selectedVoice]) {
1786
+ config.defaultVoice = voiceMetadata.voices[selectedVoice].id;
1787
+ console.log(chalk.green(`\nāœ“ Voice selected: ${voiceMetadata.voices[selectedVoice].displayName} (${config.defaultVoice})\n`));
1788
+ } else {
1789
+ config.defaultVoice = selectedVoice;
1790
+ console.log(chalk.green(`\nāœ“ Voice selected: ${selectedVoice}\n`));
1791
+ }
1792
+
1793
+ // Show hint about voice browser
1794
+ console.log(boxen(
1795
+ chalk.cyan('šŸ’” Want to explore 914+ voices?\n\n') +
1796
+ chalk.white('Run: ') + chalk.yellow('npx agentvibes-voice-browser') + chalk.gray('\n\nBrowse all LibriTTS voices with preview, search, and install.'),
1797
+ {
1798
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
1799
+ margin: { top: 0, bottom: 1, left: 0, right: 0 },
1800
+ borderStyle: 'round',
1801
+ borderColor: 'cyan',
1802
+ dimBorder: true
1803
+ }
1804
+ ));
1805
+
1179
1806
  // Auto-advance to next page after selection
1180
- console.log(chalk.green(`\nāœ“ Voice selected: ${selectedVoice}\n`));
1181
1807
  currentPage++; // Skip to next page immediately
1182
1808
  continue; // Skip navigation and go to next iteration
1183
1809
  } else {
@@ -1215,9 +1841,24 @@ async function collectConfiguration(options = {}) {
1215
1841
  }
1216
1842
 
1217
1843
  if (selectedVoice !== '__skip__') {
1844
+ // macOS voices use their name directly (no piper metadata conversion needed)
1218
1845
  config.defaultVoice = selectedVoice;
1219
- // Auto-advance to next page after selection
1220
1846
  console.log(chalk.green(`\nāœ“ Voice selected: ${selectedVoice}\n`));
1847
+
1848
+ // Show hint about voice browser
1849
+ console.log(boxen(
1850
+ chalk.cyan('šŸ’” Want to explore 914+ voices?\n\n') +
1851
+ chalk.white('Run: ') + chalk.yellow('npx agentvibes-voice-browser') + chalk.gray('\n\nBrowse all LibriTTS voices with preview, search, and install.'),
1852
+ {
1853
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
1854
+ margin: { top: 0, bottom: 1, left: 0, right: 0 },
1855
+ borderStyle: 'round',
1856
+ borderColor: 'cyan',
1857
+ dimBorder: true
1858
+ }
1859
+ ));
1860
+
1861
+ // Auto-advance to next page after selection
1221
1862
  currentPage++; // Skip to next page immediately
1222
1863
  continue; // Skip navigation and go to next iteration
1223
1864
  } else {
@@ -1314,7 +1955,69 @@ async function collectConfiguration(options = {}) {
1314
1955
  }
1315
1956
 
1316
1957
  } else if (currentPage === 3) {
1317
- // Page 4: Personality Selection
1958
+ // Page 4: Pretext and Personality Selection
1959
+ console.log(boxen(
1960
+ chalk.white('Customize your Agent\'s introduction!\n\n') +
1961
+ chalk.gray('Add optional intro text that prefixes all TTS messages.\n') +
1962
+ chalk.gray('Examples: "FireBot: ", "Agent: ", "šŸ¤– Assistant: "\n\n') +
1963
+ chalk.gray('You can change this anytime with: ') + chalk.cyan('/agent-vibes:set-pretext <text>'),
1964
+ {
1965
+ padding: 1,
1966
+ margin: { top: 0, bottom: 0, left: 0, right: 0 },
1967
+ borderStyle: 'round',
1968
+ borderColor: 'gray',
1969
+ width: 80
1970
+ }
1971
+ ));
1972
+
1973
+ // Pretext input with validation
1974
+ let pretextValid = false;
1975
+ let pretext = config.pretext || '';
1976
+
1977
+ while (!pretextValid) {
1978
+ const { pretextInput } = await inquirer.prompt([{
1979
+ type: 'input',
1980
+ name: 'pretextInput',
1981
+ message: chalk.yellow('Enter intro text (optional, max 50 chars):'),
1982
+ default: pretext,
1983
+ // Live character count display in prompt
1984
+ prefix: `${chalk.cyan('[Intro Text]')}`,
1985
+ validate: (input) => {
1986
+ // Trim first for consistent length checking
1987
+ const trimmed = input.trim();
1988
+ // Check for newlines
1989
+ if (input.includes('\n') || input.includes('\r')) {
1990
+ return chalk.red('Error: Newlines not allowed');
1991
+ }
1992
+ // Check length after trimming for consistency with display
1993
+ if (trimmed.length > 50) {
1994
+ return chalk.red(`Too long: ${trimmed.length}/50 characters`);
1995
+ }
1996
+ return true;
1997
+ },
1998
+ filter: (input) => {
1999
+ // Trim and treat whitespace-only as empty
2000
+ const trimmed = input.trim();
2001
+ return trimmed;
2002
+ }
2003
+ }]);
2004
+
2005
+ pretext = pretextInput;
2006
+
2007
+ // Display preview
2008
+ if (pretext) {
2009
+ console.log(chalk.yellow(`\nCharacter count: ${pretext.length}/50`));
2010
+ console.log(chalk.cyan(`Preview: "${pretext}" This is how it will sound\n`));
2011
+ console.log(chalk.green(`āœ“ Intro text set: "${pretext}"\n`));
2012
+ } else {
2013
+ console.log(chalk.gray('→ No intro text (messages will speak normally)\n'));
2014
+ }
2015
+
2016
+ config.pretext = pretext;
2017
+ pretextValid = true;
2018
+ }
2019
+
2020
+ // Page 5: Personality Selection
1318
2021
  console.log(boxen(
1319
2022
  chalk.white('Give your Agent a personality!\n\n') +
1320
2023
  chalk.gray('Personalities add character and style to TTS responses.\n') +
@@ -1334,6 +2037,9 @@ async function collectConfiguration(options = {}) {
1334
2037
  const personalitiesDir = path.join(__dirname, '..', '.claude', 'personalities');
1335
2038
  let personalityChoices = [];
1336
2039
 
2040
+ // Story 2.3 & 2.4: Check emoji support once, pass to all personality icons (performance fix)
2041
+ const emojiSupported = supportsEmoji();
2042
+
1337
2043
  try {
1338
2044
  const personalityFiles = await fs.readdir(personalitiesDir);
1339
2045
  const personalities = [];
@@ -1359,16 +2065,18 @@ async function collectConfiguration(options = {}) {
1359
2065
  personalities.sort((a, b) => a.name.localeCompare(b.name));
1360
2066
 
1361
2067
  // Add "none" as first option (default)
2068
+ const noneIcon = getPersonalityIcon('none', emojiSupported);
1362
2069
  personalityChoices.push(
1363
- { name: chalk.green('none') + chalk.gray(' (Professional, no personality) - Recommended'), value: 'none' },
2070
+ { name: noneIcon + ' ' + chalk.green('none') + chalk.gray(' (Professional, no personality) - Recommended'), value: 'none' },
1364
2071
  new inquirer.Separator(chalk.gray('─'.repeat(60)))
1365
2072
  );
1366
2073
 
1367
2074
  // Add all other personalities
1368
2075
  for (const p of personalities) {
1369
2076
  if (p.name !== 'normal') { // Skip 'normal' as it's similar to 'none'
2077
+ const icon = getPersonalityIcon(p.name, emojiSupported);
1370
2078
  personalityChoices.push({
1371
- name: chalk.cyan(p.name) + chalk.gray(` - ${p.description}`),
2079
+ name: icon + ' ' + chalk.cyan(p.name) + chalk.gray(` - ${p.description}`),
1372
2080
  value: p.name
1373
2081
  });
1374
2082
  }
@@ -1387,14 +2095,28 @@ async function collectConfiguration(options = {}) {
1387
2095
  ];
1388
2096
  }
1389
2097
 
1390
- const { selectedPersonality } = await inquirer.prompt([{
1391
- type: 'list',
1392
- name: 'selectedPersonality',
1393
- message: chalk.yellow('Select your default personality:'),
1394
- choices: personalityChoices,
1395
- default: 'none',
2098
+ // Story 2.3 & 2.4: Show emoji support status in help text (reuse emojiSupported from above)
2099
+ const emojiNote = emojiSupported
2100
+ ? chalk.gray('(Emoji icons shown for visual recognition)')
2101
+ : chalk.gray('(Text labels shown - emoji not supported in this terminal)');
2102
+
2103
+ // Use search prompt for keyboard navigation (type to filter)
2104
+ const selectedPersonality = await search({
2105
+ message: chalk.yellow('Select your default personality (type to search):') + ' ' + emojiNote,
2106
+ source: async (input) => {
2107
+ // Filter personalityChoices based on input
2108
+ if (!input) {
2109
+ return personalityChoices;
2110
+ }
2111
+ return personalityChoices.filter(choice => {
2112
+ // Check if choice is a Separator, if so skip it
2113
+ if (!choice.value) return false;
2114
+ return choice.value.toLowerCase().includes(input.toLowerCase()) ||
2115
+ (choice.name && choice.name.toLowerCase().includes(input.toLowerCase()));
2116
+ });
2117
+ },
1396
2118
  pageSize: 15
1397
- }]);
2119
+ });
1398
2120
 
1399
2121
  if (selectedPersonality === '__back__') {
1400
2122
  currentPage--; // Go back to voice selection
@@ -1487,15 +2209,45 @@ async function collectConfiguration(options = {}) {
1487
2209
  { name: 'Light (Small room) - Recommended', value: 'light' },
1488
2210
  { name: 'Medium (Conference room)', value: 'medium' },
1489
2211
  { name: 'Heavy (Large hall)', value: 'heavy' },
1490
- { name: 'Cathedral (Epic space)', value: 'cathedral' }
2212
+ { name: 'Cathedral (Epic space)', value: 'cathedral' },
2213
+ new inquirer.Separator(),
2214
+ { name: chalk.magentaBright('← Previous'), value: '__back__' }
1491
2215
  ],
1492
2216
  default: config.reverb || 'light'
1493
2217
  }]);
1494
2218
 
2219
+ if (reverbLevel === '__back__') {
2220
+ currentPage--;
2221
+ continue;
2222
+ }
2223
+
1495
2224
  config.reverb = reverbLevel;
1496
2225
 
1497
- // Add spacing before next question
1498
- console.log('');
2226
+ console.log(chalk.green('\nāœ“ Reverb level set\n'));
2227
+ currentPage++;
2228
+ continue;
2229
+
2230
+ } else if (currentPage === 5) {
2231
+ // Page 6: Background Music Settings
2232
+
2233
+ // Skip for termux-ssh - background music doesn't work with SSH text-only TTS
2234
+ if (config.provider === 'termux-ssh' || config.provider === 'ssh-pulseaudio') {
2235
+ console.log(boxen(
2236
+ chalk.white('SSH-Remote: Audio Effects Apply on Android\n\n') +
2237
+ chalk.green('āœ… Background music works:\n') +
2238
+ chalk.gray(' • Music plays on Android device\n') +
2239
+ chalk.gray(' • All settings configured below will apply\n\n') +
2240
+ chalk.cyan('Configure background music below!'),
2241
+ {
2242
+ padding: 1,
2243
+ margin: { top: 0, bottom: 0, left: 0, right: 0 },
2244
+ borderStyle: 'round',
2245
+ borderColor: 'green',
2246
+ width: 80
2247
+ }
2248
+ ));
2249
+ console.log('');
2250
+ }
1499
2251
 
1500
2252
  // Background music
1501
2253
  console.log(chalk.gray('šŸŽµ Background music plays ambient tracks during TTS for a more engaging experience.'));
@@ -1510,48 +2262,194 @@ async function collectConfiguration(options = {}) {
1510
2262
  config.backgroundMusic.enabled = enableMusic;
1511
2263
 
1512
2264
  if (enableMusic) {
2265
+ // Check if an MP3-capable player is available; offer to install ffmpeg if not
2266
+ const { execSync: _execSync } = await import('child_process');
2267
+ const _mp3Players = ['ffplay', 'mpg123', 'mpv', 'cvlc'];
2268
+ const _hasPlayer = _mp3Players.some(p => {
2269
+ try { _execSync(`which ${p}`, { stdio: 'pipe' }); return true; } catch { return false; }
2270
+ });
2271
+ if (!_hasPlayer) {
2272
+ console.log('');
2273
+ console.log(chalk.yellow('āš ļø No MP3 player found — background music requires one.'));
2274
+ console.log(chalk.gray(' ffmpeg is recommended (provides ffplay for MP3 playback).\n'));
2275
+
2276
+ const _osPlatform = process.platform;
2277
+ let _installCmd;
2278
+ if (_osPlatform === 'darwin') {
2279
+ _installCmd = 'brew install ffmpeg';
2280
+ } else {
2281
+ // Prefer pkexec (GUI password dialog) when available — works in
2282
+ // environments where sudo lacks a tty (e.g., AI assistant terminals).
2283
+ // Fall back to sudo for headless/SSH setups.
2284
+ let _hasPkexec = false;
2285
+ try { _execSync('which pkexec', { stdio: 'pipe' }); _hasPkexec = true; } catch {}
2286
+ _installCmd = _hasPkexec
2287
+ ? 'pkexec apt-get install -y ffmpeg'
2288
+ : 'sudo apt-get install -y ffmpeg';
2289
+ }
2290
+
2291
+ if (!options.yes) {
2292
+ const { installFfmpeg } = await inquirer.prompt([{
2293
+ type: 'confirm',
2294
+ name: 'installFfmpeg',
2295
+ message: chalk.yellow(`Install ffmpeg now? (${_installCmd})`),
2296
+ default: true,
2297
+ }]);
2298
+ if (installFfmpeg) {
2299
+ try {
2300
+ console.log(chalk.cyan(`\nšŸ“¦ Running: ${_installCmd}\n`));
2301
+ const { execSync: _exec } = await import('child_process');
2302
+ _exec(_installCmd, { stdio: 'inherit', timeout: 120000 });
2303
+ console.log(chalk.green('\nāœ… ffmpeg installed successfully!\n'));
2304
+ } catch {
2305
+ console.log(chalk.yellow('\nāš ļø ffmpeg installation failed. You can install it manually:'));
2306
+ console.log(chalk.cyan(` ${_installCmd}\n`));
2307
+ }
2308
+ } else {
2309
+ console.log(chalk.gray(` Install later with: ${_installCmd}\n`));
2310
+ }
2311
+ }
2312
+ }
2313
+
2314
+ // Check for sox — required to mix background music into TTS audio
2315
+ let _hasSox = false;
2316
+ try { _execSync('which sox', { stdio: 'pipe' }); _hasSox = true; } catch {}
2317
+ if (!_hasSox) {
2318
+ console.log('');
2319
+ console.log(chalk.yellow('āš ļø sox not found — required to mix background music into voice audio.'));
2320
+ console.log(chalk.gray(' Without sox, you\'ll hear voice only, no background music.\n'));
2321
+
2322
+ const _osPlatform2 = process.platform;
2323
+ let _soxCmd;
2324
+ if (_osPlatform2 === 'darwin') {
2325
+ _soxCmd = 'brew install sox';
2326
+ } else {
2327
+ let _hasPkexec2 = false;
2328
+ try { _execSync('which pkexec', { stdio: 'pipe' }); _hasPkexec2 = true; } catch {}
2329
+ _soxCmd = _hasPkexec2
2330
+ ? 'pkexec apt-get install -y sox libsox-fmt-mp3'
2331
+ : 'sudo apt-get install -y sox libsox-fmt-mp3';
2332
+ }
2333
+
2334
+ if (!options.yes) {
2335
+ const { installSox } = await inquirer.prompt([{
2336
+ type: 'confirm',
2337
+ name: 'installSox',
2338
+ message: chalk.yellow(`Install sox now? (${_soxCmd})`),
2339
+ default: true,
2340
+ }]);
2341
+ if (installSox) {
2342
+ try {
2343
+ console.log(chalk.cyan(`\nšŸ“¦ Running: ${_soxCmd}\n`));
2344
+ _execSync(_soxCmd, { stdio: 'inherit', timeout: 120000 });
2345
+ console.log(chalk.green('\nāœ… sox installed successfully!\n'));
2346
+ } catch {
2347
+ console.log(chalk.yellow('\nāš ļø sox installation failed. You can install it manually:'));
2348
+ console.log(chalk.cyan(` ${_soxCmd}\n`));
2349
+ }
2350
+ } else {
2351
+ console.log(chalk.gray(` Install later with: ${_soxCmd}\n`));
2352
+ }
2353
+ }
2354
+ }
2355
+
1513
2356
  // Add spacing before track selection
1514
2357
  console.log('');
1515
2358
  console.log(chalk.gray('šŸŽ¼ Choose your default background music genre (you can change this anytime).'));
1516
2359
 
2360
+ // Load custom tracks from registry
2361
+ const customTracks = await loadCustomTracks();
2362
+ const customTrackChoices = customTracks.map(track => ({
2363
+ name: `šŸ“ ${track.name} ` + chalk.gray('[SPACE to preview]'),
2364
+ value: track.filename
2365
+ }));
2366
+
1517
2367
  const trackChoices = [
1518
- { name: 'šŸŽ» Soft Flamenco (Spanish guitar)', value: 'agentvibes_soft_flamenco_loop.mp3' },
1519
- { name: 'šŸŽŗ Bachata (Latin - Romantic guitar & bongos)', value: 'agent_vibes_bachata_v1_loop.mp3' },
1520
- { name: 'šŸ’ƒ Salsa (Latin - Upbeat brass & percussion)', value: 'agent_vibes_salsa_v2_loop.mp3' },
1521
- { name: 'šŸŽø Cumbia (Latin - Accordion & drums)', value: 'agent_vibes_cumbia_v1_loop.mp3' },
1522
- { name: '🌸 Bossa Nova (Brazilian jazz)', value: 'agent_vibes_bossa_nova_v2_loop.mp3' },
1523
- { name: 'šŸ™ļø Japanese City Pop (80s synth)', value: 'agent_vibes_japanese_city_pop_v1_loop.mp3' },
1524
- { name: '🌊 Chillwave (Electronic ambient)', value: 'agent_vibes_chillwave_v2_loop.mp3' },
1525
- { name: 'šŸŽ¹ Dreamy House (Electronic dance)', value: 'dreamy_house_loop.mp3' },
1526
- { name: 'šŸŒ™ Dark Chill Step (Electronic bass)', value: 'agent_vibes_dark_chill_step_loop.mp3' },
1527
- { name: 'šŸ•‰ļø Goa Trance (Psychedelic electronic)', value: 'agent_vibes_goa_trance_v2_loop.mp3' },
1528
- { name: 'šŸŽ¼ Harpsichord (Baroque classical)', value: 'agent_vibes_harpsichord_v2_loop.mp3' },
1529
- { name: 'šŸŽ» Celtic Harp (Irish traditional)', value: 'agent_vibes_celtic_harp_v1_loop.mp3' },
1530
- { name: '🌺 Hawaiian Slack Key Guitar', value: 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3' },
1531
- { name: 'šŸœļø Arabic Oud (Middle Eastern)', value: 'agent_vibes_arabic_v2_loop.mp3' },
1532
- { name: '🪘 Gnawa Ambient (North African)', value: 'agent_vibes_ganawa_ambient_v2_loop.mp3' },
1533
- { name: '🄁 Tabla Dream Pop (Indian percussion)', value: 'agent_vibes_tabla_dream_pop_v1_loop.mp3' }
2368
+ { name: 'šŸŽ» Soft Flamenco (Spanish guitar) ' + chalk.gray('[SPACE to preview]'), value: 'agentvibes_soft_flamenco_loop.mp3' },
2369
+ { name: 'šŸŽŗ Bachata (Latin - Romantic guitar & bongos) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_bachata_v1_loop.mp3' },
2370
+ { name: 'šŸ’ƒ Salsa (Latin - Upbeat brass & percussion) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_salsa_v2_loop.mp3' },
2371
+ { name: 'šŸŽø Cumbia (Latin - Accordion & drums) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_cumbia_v1_loop.mp3' },
2372
+ { name: '🌸 Bossa Nova (Brazilian jazz) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_bossa_nova_v2_loop.mp3' },
2373
+ { name: 'šŸ™ļø Japanese City Pop (80s synth) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_japanese_city_pop_v1_loop.mp3' },
2374
+ { name: '🌊 Chillwave (Electronic ambient) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_chillwave_v2_loop.mp3' },
2375
+ { name: 'šŸŒ™ Dark Chill Step (Electronic bass) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_dark_chill_step_loop.mp3' },
2376
+ { name: 'šŸ•‰ļø Goa Trance (Psychedelic electronic) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_goa_trance_v2_loop.mp3' },
2377
+ { name: 'šŸŽ¼ Harpsichord (Baroque classical) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_harpsichord_v2_loop.mp3' },
2378
+ { name: 'šŸŽ» Celtic Harp (Irish traditional) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_celtic_harp_v1_loop.mp3' },
2379
+ { name: '🌺 Hawaiian Slack Key Guitar ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3' },
2380
+ { name: 'šŸœļø Arabic Oud (Middle Eastern) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_arabic_v2_loop.mp3' },
2381
+ { name: '🪘 Gnawa Ambient (North African) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_ganawa_ambient_v2_loop.mp3' },
2382
+ { name: '🄁 Tabla Dream Pop (Indian percussion) ' + chalk.gray('[SPACE to preview]'), value: 'agent_vibes_tabla_dream_pop_v1_loop.mp3' }
1534
2383
  ];
1535
2384
 
1536
- const { selectedTrack } = await inquirer.prompt([{
1537
- type: 'list',
2385
+ // Add custom tracks separator and options if any exist
2386
+ if (customTrackChoices.length > 0) {
2387
+ trackChoices.push(
2388
+ new inquirer.Separator(chalk.gray('─'.repeat(50))),
2389
+ ...customTrackChoices
2390
+ );
2391
+ }
2392
+
2393
+ // Add custom track option
2394
+ trackChoices.push(
2395
+ new inquirer.Separator(chalk.gray('─'.repeat(50))),
2396
+ { name: 'āž• Add Custom Track...', value: '__custom__' }
2397
+ );
2398
+
2399
+ // Interactive track selection - Enter=Select, Spacebar=Preview
2400
+ const tracksDir = path.join(__dirname, '..', '.claude', 'audio', 'tracks');
2401
+
2402
+ const result = await createPreviewListPrompt(inquirer, {
1538
2403
  name: 'selectedTrack',
1539
- message: chalk.yellow('Choose default background music track:'),
2404
+ message: chalk.yellow('Choose background music:'),
1540
2405
  choices: trackChoices,
1541
2406
  default: config.backgroundMusic.track || 'agentvibes_soft_flamenco_loop.mp3',
1542
- pageSize: 16
1543
- }]);
2407
+ pageSize: 18,
2408
+ loop: false,
2409
+ onPreview: async (trackFile) => {
2410
+ console.log(chalk.cyan('\n šŸ”Š Playing preview...\n'));
2411
+ await previewAudioTrack(trackFile, tracksDir);
2412
+ }
2413
+ });
2414
+ const selectedTrack = result.selectedTrack;
2415
+
2416
+ // Handle custom track selection
2417
+ if (selectedTrack === '__custom__') {
2418
+ console.log('');
2419
+ const result = await promptForCustomMusic(claudeDir);
2420
+
2421
+ if (result.success && result.filename) {
2422
+ config.backgroundMusic.track = result.filename;
2423
+
2424
+ // Update registry
2425
+ const allCustomTracks = await loadCustomTracks();
2426
+ if (!allCustomTracks.some(t => t.filename === result.filename)) {
2427
+ const trackName = path.basename(result.filename, path.extname(result.filename));
2428
+ allCustomTracks.push({ name: trackName, filename: result.filename });
2429
+ await saveCustomTracks(allCustomTracks);
2430
+ }
2431
+ } else {
2432
+ // Fallback to default
2433
+ config.backgroundMusic.track = 'agentvibes_soft_flamenco_loop.mp3';
2434
+ if (result.error) {
2435
+ console.log(chalk.yellow(`āš ļø ${result.error}`));
2436
+ }
2437
+ console.log(chalk.yellow('āš ļø Using default track'));
2438
+ }
2439
+ } else {
2440
+ config.backgroundMusic.track = selectedTrack;
2441
+ }
1544
2442
 
1545
- config.backgroundMusic.track = selectedTrack;
2443
+ console.log(chalk.green(`\nāœ“ Selected: ${config.backgroundMusic.track}\n`));
1546
2444
  }
1547
2445
 
1548
2446
  // Auto-advance to next page after audio settings
1549
- console.log(chalk.green('\nāœ“ Audio settings configured\n'));
2447
+ console.log(chalk.green('āœ“ Background music configured\n'));
1550
2448
  currentPage++;
1551
2449
  continue;
1552
2450
 
1553
- } else if (currentPage === 5) {
1554
- // Page 6: Verbosity Settings
2451
+ } else if (currentPage === 6) {
2452
+ // Page 7: Verbosity Settings
1555
2453
  console.log(boxen(
1556
2454
  chalk.white('Choose how much Claude speaks during interactions.\n\n') +
1557
2455
  chalk.yellow('šŸ”Š High:\n') +
@@ -1582,23 +2480,39 @@ async function collectConfiguration(options = {}) {
1582
2480
  choices: [
1583
2481
  { name: 'šŸ”Š High - Maximum transparency', value: 'high' },
1584
2482
  { name: 'šŸ”‰ Medium - Balanced', value: 'medium' },
1585
- { name: 'šŸ”ˆ Low - Minimal', value: 'low' }
2483
+ { name: 'šŸ”ˆ Low - Minimal', value: 'low' },
2484
+ new inquirer.Separator(),
2485
+ { name: chalk.magentaBright('← Previous'), value: '__back__' }
1586
2486
  ],
1587
2487
  default: config.verbosity || 'high'
1588
2488
  }]);
1589
2489
 
2490
+ if (verbosity === '__back__') {
2491
+ currentPage--;
2492
+ continue;
2493
+ }
2494
+
1590
2495
  config.verbosity = verbosity;
1591
2496
 
1592
- // Auto-advance - verbosity is the last page, so we're done
2497
+ // Show confirmation and auto-advance to next page
1593
2498
  console.log(chalk.green('\nāœ“ Verbosity level set\n'));
1594
2499
  currentPage++;
1595
2500
  continue;
1596
2501
  }
1597
2502
 
1598
- // Navigation
2503
+ // Auto-advance if provider was just detected (skip navigation prompt)
2504
+ if (config._autoAdvance) {
2505
+ delete config._autoAdvance;
2506
+ await new Promise(resolve => setTimeout(resolve, 800)); // Brief pause
2507
+ currentPage++;
2508
+ continue;
2509
+ }
2510
+
2511
+ // Navigation with page titles
1599
2512
  const navChoices = [];
1600
2513
  if (currentPage < totalPages - 1) {
1601
- navChoices.push({ name: chalk.green('Next →'), value: 'next' });
2514
+ const nextPageTitle = getPageTitle(currentPage + 1).replace(/[šŸ”§šŸŽ™ļøšŸŽ¤šŸ˜ŽšŸ’§šŸ”Š]\s*/, ''); // Remove emoji
2515
+ navChoices.push({ name: chalk.green('Next →') + chalk.gray(` (${nextPageTitle})`), value: 'next' });
1602
2516
  } else {
1603
2517
  navChoices.push({ name: chalk.cyan('āœ“ Continue to Installation'), value: 'continue' });
1604
2518
  }
@@ -1607,13 +2521,23 @@ async function collectConfiguration(options = {}) {
1607
2521
  if (currentPage === 0) {
1608
2522
  navChoices.push({ name: chalk.magentaBright('← Back to Welcome'), value: 'back' });
1609
2523
  } else {
1610
- navChoices.push({ name: chalk.magentaBright('← Previous'), value: 'prev' });
2524
+ const prevPageTitle = getPageTitle(currentPage - 1).replace(/[šŸ”§šŸŽ™ļøšŸŽ¤šŸ˜ŽšŸ’§šŸ”Š]\s*/, ''); // Remove emoji
2525
+ navChoices.push({ name: chalk.magentaBright('← Previous') + chalk.gray(` (${prevPageTitle})`), value: 'prev' });
2526
+ }
2527
+
2528
+ // Set navigation message based on page status
2529
+ let navMessage = '';
2530
+ if (currentPage === 0 && pageStatus) {
2531
+ navMessage = pageStatus.allMet
2532
+ ? chalk.green('āœ“') + chalk.cyan(' All system dependencies met')
2533
+ : chalk.yellow('⚠') + chalk.cyan(` ${pageStatus.missingCount} optional tool${pageStatus.missingCount > 1 ? 's' : ''} missing`);
1611
2534
  }
1612
2535
 
1613
2536
  const { action } = await inquirer.prompt([{
1614
2537
  type: 'list',
1615
2538
  name: 'action',
1616
- message: '',
2539
+ message: navMessage,
2540
+ prefix: '',
1617
2541
  choices: navChoices,
1618
2542
  default: 'next'
1619
2543
  }]);
@@ -1690,22 +2614,22 @@ function showWelcome() {
1690
2614
  * Shown during install and update commands
1691
2615
  */
1692
2616
  function getReleaseInfoBoxen() {
1693
- return chalk.cyan.bold('šŸ“¦ AgentVibes v3.5.8 - Provider Validation Security & UX Improvements\n\n') +
2617
+ return chalk.cyan.bold('šŸ“¦ AgentVibes v4.0.0 - Interactive Console & Voice Explorer\n\n') +
1694
2618
  chalk.green.bold('šŸŽ™ļø WHAT\'S NEW:\n\n') +
1695
- chalk.cyan('Critical security and reliability update for provider detection. Fixes command injection\n') +
1696
- chalk.cyan('vulnerabilities, prevents HOME directory injection attacks, and improves UX with explicit\n') +
1697
- chalk.cyan('provider detection messaging. Soprano TTS installed via pipx now correctly detected.\n\n') +
2619
+ chalk.cyan('Major release with a full interactive TUI console, voice browser with 914+ voices,\n') +
2620
+ chalk.cyan('and comprehensive platform support. Includes 58 security fixes, reliable TTS hooks,\n') +
2621
+ chalk.cyan('and support for Windows, macOS, Android/Termux, and SSH-remote audio.\n\n') +
1698
2622
  chalk.green.bold('✨ KEY HIGHLIGHTS:\n\n') +
1699
- chalk.gray(' šŸ” Security Fixes - Fixed command injection, HOME injection prevention, path traversal\n') +
1700
- chalk.gray(' āœ… Provider Detection - Soprano via pipx now correctly detected\n') +
1701
- chalk.gray(' šŸ’¬ Better Messaging - Explicit detection confirmation, detailed error messages\n') +
1702
- chalk.gray(' 🧪 Enhanced Tests - Verification of actual detection values\n') +
1703
- chalk.gray(' šŸ› Debug Support - Added logging for troubleshooting\n\n') +
2623
+ chalk.gray(' šŸ–„ļø Interactive TUI Console - Settings, Voices, Music tabs with live preview\n') +
2624
+ chalk.gray(' šŸŽ¤ Voice Browser - Browse and preview 914+ Piper TTS voices\n') +
2625
+ chalk.gray(' šŸ”§ Reliable TTS Hooks - JSON context injection, auto git-init\n') +
2626
+ chalk.gray(' šŸŒ Multi-Platform - Windows, macOS, Android/Termux, SSH-remote\n') +
2627
+ chalk.gray(' šŸ” Security Hardened - 58 issues fixed, 180+ security tests\n\n') +
1704
2628
  chalk.gray('šŸ“– Full Release Notes: RELEASE_NOTES.md\n') +
1705
2629
  chalk.gray('🌐 Website: https://agentvibes.org\n') +
1706
2630
  chalk.gray('šŸ“¦ Repository: https://github.com/paulpreibisch/AgentVibes\n\n') +
1707
2631
  chalk.gray('Co-created by Paul Preibisch with Claude AI\n') +
1708
- chalk.gray('Copyright Ā© 2025 Paul Preibisch | Apache-2.0 License');
2632
+ chalk.gray('Copyright Ā© 2026 Paul Preibisch | Apache-2.0 License');
1709
2633
  }
1710
2634
 
1711
2635
  /**
@@ -2509,30 +3433,6 @@ async function copyPersonalityFiles(targetDir, spinner) {
2509
3433
  content += chalk.gray('Personalities change how Claude speaks - adding humor, emotion, or style.\n');
2510
3434
  content += chalk.gray('Change with: ') + chalk.yellow('/agent-vibes:personality <name>') + chalk.gray(' or say "change personality to sassy"\n\n');
2511
3435
 
2512
- // Map personalities to emojis
2513
- const personalityEmojis = {
2514
- 'angry': '😠',
2515
- 'annoying': '😤',
2516
- 'crass': '🤬',
2517
- 'dramatic': 'šŸŽ­',
2518
- 'dry-humor': '😐',
2519
- 'flirty': '😘',
2520
- 'funny': 'šŸ˜‚',
2521
- 'grandpa': 'šŸ‘“',
2522
- 'millennial': 'šŸ™„',
2523
- 'moody': 'šŸ˜’',
2524
- 'normal': '😊',
2525
- 'pirate': 'šŸ“ā€ā˜ ļø',
2526
- 'poetic': 'šŸ“œ',
2527
- 'professional': 'šŸ‘”',
2528
- 'rapper': 'šŸŽ¤',
2529
- 'robot': 'šŸ¤–',
2530
- 'sarcastic': 'šŸ˜',
2531
- 'sassy': 'šŸ’',
2532
- 'surfer-dude': 'šŸ„',
2533
- 'zen': '🧘'
2534
- };
2535
-
2536
3436
  // Display personalities in two columns
2537
3437
  const personalities = installedPersonalities.map(file => {
2538
3438
  const name = file.replace('.md', '');
@@ -2859,8 +3759,33 @@ async function configureSessionStartHook(targetDir, spinner) {
2859
3759
  } else {
2860
3760
  spinner.info(chalk.yellow('SessionStart hook already configured\n'));
2861
3761
  }
2862
- } catch (error) {
2863
- spinner.fail(chalk.red('Failed to configure hook: ' + error.message + '\n'));
3762
+ } catch (error) {
3763
+ spinner.fail(chalk.red('Failed to configure hook: ' + error.message + '\n'));
3764
+ }
3765
+ }
3766
+
3767
+ /**
3768
+ * Ensure target directory is a git repo (required for Claude Code hook context injection)
3769
+ * @param {string} targetDir - Target installation directory
3770
+ * @param {Object} spinner - Ora spinner instance
3771
+ */
3772
+ async function ensureGitRepo(targetDir, spinner) {
3773
+ const gitDir = path.join(targetDir, '.git');
3774
+ try {
3775
+ await fs.access(gitDir);
3776
+ // Already a git repo
3777
+ } catch {
3778
+ console.log(chalk.cyan('\nšŸ”§ Initializing git repository (required for Claude Code hooks)...'));
3779
+ try {
3780
+ const { execSync: execSyncLocal } = await import('child_process');
3781
+ execSyncLocal('git init', { cwd: targetDir, stdio: 'pipe' });
3782
+ // Stage only files that exist
3783
+ execSyncLocal('git add .', { cwd: targetDir, stdio: 'pipe' });
3784
+ execSyncLocal('git commit -m "chore: initialize AgentVibes"', { cwd: targetDir, stdio: 'pipe' });
3785
+ console.log(chalk.green('āœ“ Git repository initialized (required for TTS hooks)'));
3786
+ } catch (error) {
3787
+ console.log(chalk.yellow(`⚠ Could not initialize git repo - TTS hooks may not work: ${error.message}`));
3788
+ }
2864
3789
  }
2865
3790
  }
2866
3791
 
@@ -2932,7 +3857,7 @@ async function checkAndInstallPiper(targetDir, options) {
2932
3857
  try {
2933
3858
  if (fsSync.existsSync(piperDownloadPath)) {
2934
3859
  execScript(`${piperDownloadPath} --yes`, {
2935
- stdio: 'inherit',
3860
+ stdio: options.silent ? 'pipe' : 'inherit',
2936
3861
  env: process.env
2937
3862
  });
2938
3863
  console.log(chalk.green('\nāœ… Voice models downloaded successfully!\n'));
@@ -2976,7 +3901,7 @@ async function checkAndInstallPiper(targetDir, options) {
2976
3901
 
2977
3902
  try {
2978
3903
  execScript(`${piperInstallerPath} --non-interactive`, {
2979
- stdio: 'inherit',
3904
+ stdio: options.silent ? 'pipe' : 'inherit',
2980
3905
  env: process.env
2981
3906
  });
2982
3907
  console.log(chalk.green('\nāœ… Piper TTS installed successfully!\n'));
@@ -3522,12 +4447,9 @@ async function executeMigrationScript(migrationScript, targetDir, spinner) {
3522
4447
  try {
3523
4448
  await fs.access(migrationScript);
3524
4449
 
3525
- // Execute migration script using execFile to prevent command injection
3526
- const { execFile } = require('child_process');
3527
- const { promisify } = require('util');
3528
- const execFilePromise = promisify(execFile);
3529
-
3530
- await execFilePromise('bash', [migrationScript], { cwd: targetDir });
4450
+ // Execute migration script using execFileSync to prevent command injection
4451
+ // Uses top-level import of execFileSync (ESM-compatible, no require())
4452
+ execFileSync('bash', [migrationScript], { cwd: targetDir, stdio: 'pipe' });
3531
4453
 
3532
4454
  spinner.succeed(chalk.green('āœ“ Configuration migrated to .agentvibes/'));
3533
4455
  console.log(chalk.gray(' Old locations: .claude/config/, .claude/plugins/'));
@@ -3770,7 +4692,8 @@ async function updatePersonalityFiles(targetDir, srcPersonalitiesDir) {
3770
4692
  * @returns {Object} Mock spinner object
3771
4693
  */
3772
4694
  function createSilentSpinner() {
3773
- return { start: () => {}, succeed: () => {}, info: () => {}, fail: () => {} };
4695
+ const s = { start: () => s, succeed: () => s, info: () => s, fail: () => s, warn: () => s, stop: () => s };
4696
+ return s;
3774
4697
  }
3775
4698
 
3776
4699
  /**
@@ -3845,6 +4768,7 @@ async function performUpdateOperations(targetDir, spinner) {
3845
4768
  // Update settings.json
3846
4769
  spinner.text = 'Updating AgentVibes hook configuration...';
3847
4770
  await configureSessionStartHook(targetDir, silentSpinner);
4771
+ await ensureGitRepo(targetDir, silentSpinner);
3848
4772
 
3849
4773
  // Detect and migrate old configuration
3850
4774
  spinner.text = 'Checking for old configuration...';
@@ -3955,157 +4879,7 @@ async function install(options = {}) {
3955
4879
  const piperVoicesPath = userConfig.piperPath;
3956
4880
  const targetDir = options.directory || currentDir;
3957
4881
 
3958
- // Collect pre-install information pages
3959
- const preInstallPages = [];
3960
-
3961
- // Page 1: Configuration Summary
3962
- const providerLabels = {
3963
- piper: 'Piper TTS',
3964
- macos: 'macOS Say',
3965
- soprano: 'Soprano TTS',
3966
- 'termux-ssh': 'Termux SSH (Android)',
3967
- 'ssh-pulseaudio': 'PulseAudio Tunnel',
3968
- pulseaudio: 'PulseAudio Tunnel',
3969
- 'windows-piper': 'Windows Piper TTS',
3970
- 'windows-sapi': 'Windows SAPI'
3971
- };
3972
- const reverbLabels = {
3973
- off: 'Off',
3974
- light: 'Light',
3975
- medium: 'Medium',
3976
- heavy: 'Heavy',
3977
- cathedral: 'Cathedral'
3978
- };
3979
- const verbosityLabels = {
3980
- high: 'High',
3981
- medium: 'Medium',
3982
- low: 'Low'
3983
- };
3984
-
3985
- let configContent = chalk.bold('Your Configuration\n\n');
3986
- configContent += chalk.cyan('šŸŽ¤ TTS Provider:\n');
3987
- configContent += chalk.white(` ${providerLabels[selectedProvider]}\n`);
3988
- if (selectedProvider === 'piper' && piperVoicesPath) {
3989
- configContent += chalk.gray(` Voice storage: ${piperVoicesPath}\n`);
3990
- }
3991
- if (selectedProvider === 'termux-ssh') {
3992
- if (userConfig.sshHost) {
3993
- configContent += chalk.gray(` SSH host: ${userConfig.sshHost}\n`);
3994
- } else {
3995
- configContent += chalk.yellow(` SSH host: Not configured (set later)\n`);
3996
- }
3997
- }
3998
- configContent += '\n';
3999
- configContent += chalk.cyan('šŸŽ›ļø Audio Settings:\n');
4000
- configContent += chalk.white(` Reverb: ${reverbLabels[userConfig.reverb]}\n`);
4001
- configContent += chalk.white(` Background Music: ${userConfig.backgroundMusic.enabled ? 'Enabled' : 'Disabled'}\n`);
4002
- if (userConfig.backgroundMusic.enabled) {
4003
- // Find the track name from the track choices
4004
- const trackChoices = {
4005
- 'agentvibes_soft_flamenco_loop.mp3': 'Soft Flamenco',
4006
- 'agent_vibes_bachata_v1_loop.mp3': 'Bachata',
4007
- 'agent_vibes_salsa_v2_loop.mp3': 'Salsa',
4008
- 'agent_vibes_cumbia_v1_loop.mp3': 'Cumbia',
4009
- 'agent_vibes_bossa_nova_v2_loop.mp3': 'Bossa Nova',
4010
- 'agent_vibes_japanese_city_pop_v1_loop.mp3': 'Japanese City Pop',
4011
- 'agent_vibes_chillwave_v2_loop.mp3': 'Chillwave',
4012
- 'dreamy_house_loop.mp3': 'Dreamy House',
4013
- 'agent_vibes_dark_chill_step_loop.mp3': 'Dark Chill Step',
4014
- 'agent_vibes_goa_trance_v2_loop.mp3': 'Goa Trance',
4015
- 'agent_vibes_harpsichord_v2_loop.mp3': 'Harpsichord',
4016
- 'agent_vibes_celtic_harp_v1_loop.mp3': 'Celtic Harp',
4017
- 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': 'Hawaiian Slack Key Guitar',
4018
- 'agent_vibes_arabic_v2_loop.mp3': 'Arabic Oud',
4019
- 'agent_vibes_ganawa_ambient_v2_loop.mp3': 'Gnawa Ambient',
4020
- 'agent_vibes_tabla_dream_pop_v1_loop.mp3': 'Tabla Dream Pop'
4021
- };
4022
- const trackName = trackChoices[userConfig.backgroundMusic.track] || userConfig.backgroundMusic.track;
4023
- configContent += chalk.gray(` Default track: ${trackName}\n`);
4024
- }
4025
- configContent += '\n';
4026
- configContent += chalk.cyan('šŸ˜Ž Personality:\n');
4027
- const personalityDisplay = userConfig.personality === 'none' ? 'None (Professional)' : userConfig.personality.charAt(0).toUpperCase() + userConfig.personality.slice(1);
4028
- configContent += chalk.white(` ${personalityDisplay}\n`);
4029
- configContent += '\n';
4030
- configContent += chalk.cyan('šŸ”Š Verbosity:\n');
4031
- configContent += chalk.white(` ${verbosityLabels[userConfig.verbosity]}\n`);
4032
-
4033
- const configBoxen = boxen(configContent.trim(), {
4034
- padding: 1,
4035
- margin: { top: 0, bottom: 0, left: 0, right: 0 },
4036
- borderStyle: 'round',
4037
- borderColor: 'green',
4038
- width: 80
4039
- });
4040
-
4041
- // Don't add Configuration Summary to preInstallPages yet - we'll handle it specially
4042
-
4043
- // Show pre-install pages up to (but NOT including) Configuration Summary
4044
- if (!options.yes && preInstallPages.length > 0) {
4045
- console.log(chalk.cyan('\nšŸ“– Installation Preview\n'));
4046
- const preInstallOffset = 4; // After 4 config pages (System Dependencies + Provider + Audio + Verbosity)
4047
- // For pre-install, estimate post-install pages (will be exact in post-install)
4048
- const estimatedPostInstall = 7; // Typical: 5 summaries + 1 BMAD/recommendation + 1 complete
4049
- const estimatedTotal = configPages + preInstallPages.length + 1 + estimatedPostInstall; // +1 for Config Summary
4050
-
4051
- const result = await showPaginatedContent(preInstallPages, {
4052
- ...options,
4053
- continueLabel: 'āœ“ Next',
4054
- pageOffset: preInstallOffset,
4055
- totalPages: estimatedTotal,
4056
- showPreviousOnFirst: true
4057
- });
4058
-
4059
- // If user went back from first pre-install page, restart configuration
4060
- if (result === 'prev') {
4061
- console.log(chalk.yellow('\nā†©ļø Returning to configuration...\n'));
4062
- return install(options); // Restart the install function
4063
- }
4064
- }
4065
-
4066
- // Handle Configuration Summary page specially with welcome message prompt
4067
- if (!options.yes) {
4068
- // Show Configuration Summary page
4069
- console.clear();
4070
- const currentPageNum = 4 + preInstallPages.length; // After config pages + pre-install pages
4071
- const estimatedPostInstall = 7;
4072
- const estimatedTotal = configPages + preInstallPages.length + 1 + estimatedPostInstall;
4073
- const { header } = createPageHeaderFooter('Configuration Summary', currentPageNum, estimatedTotal, 0);
4074
-
4075
- console.log(header);
4076
- console.log(configBoxen);
4077
- console.log('');
4078
- // Don't show welcome message text for termux-ssh (it won't work)
4079
- if (userConfig.provider !== 'termux-ssh') {
4080
- console.log(chalk.gray('Play audio welcome message from Paul, creator of AgentVibes.\n'));
4081
- }
4082
- }
4083
-
4084
- // Ask welcome message question BEFORE showing navigation
4085
- // Skip for termux-ssh - welcome audio plays locally, not on Android device
4086
- if (!options.yes && userConfig.provider !== 'termux-ssh') {
4087
- // Ask if user wants to hear welcome message
4088
- const { playWelcome } = await inquirer.prompt([
4089
- {
4090
- type: 'confirm',
4091
- name: 'playWelcome',
4092
- message: chalk.yellow('šŸŽµ Listen to Welcome Message?'),
4093
- default: false,
4094
- },
4095
- ]);
4096
-
4097
- if (playWelcome) {
4098
- console.log(''); // Spacing before spinner
4099
- const spinner = ora('Playing welcome message...').start();
4100
- await playWelcomeDemo(targetDir, spinner, options);
4101
- spinner.succeed(chalk.green('Welcome message complete!'));
4102
- console.log(''); // Spacing after completion
4103
- }
4104
- } else if (!options.yes && userConfig.provider === 'termux-ssh' || userConfig.provider === 'ssh-pulseaudio') {
4105
- console.log(chalk.yellow('⊘ Welcome message skipped (not available for Termux SSH)\n'));
4106
- }
4107
-
4108
- // Now show navigation menu (Continue to installation)
4882
+ // Confirm and start installation
4109
4883
  const { startInstall } = await inquirer.prompt([
4110
4884
  {
4111
4885
  type: 'confirm',
@@ -4120,59 +4894,36 @@ async function install(options = {}) {
4120
4894
  process.exit(0);
4121
4895
  }
4122
4896
 
4123
- // User already confirmed by pressing "Start Installation", so no need for another confirmation
4897
+ // Silent spinner for copy functions — suppresses per-file output
4898
+ const silentSpinner = createSilentSpinner();
4899
+
4124
4900
  console.log('');
4125
- const spinner = ora('Checking installation directory...').start();
4901
+ const spinner = ora('Installing AgentVibes...').start();
4126
4902
 
4127
4903
  try {
4128
4904
  // Create .claude directory structure
4129
4905
  const claudeDir = path.join(targetDir, '.claude');
4130
4906
  const commandsDir = path.join(claudeDir, 'commands');
4131
4907
  const hooksDir = path.join(claudeDir, isNativeWindows() ? 'hooks-windows' : 'hooks');
4132
-
4133
- let exists = false;
4134
- try {
4135
- await fs.access(claudeDir);
4136
- exists = true;
4137
- } catch {}
4138
-
4139
- if (!exists) {
4140
- spinner.info(chalk.yellow('Creating .claude directory structure...'));
4141
- const audioDir = path.join(claudeDir, 'audio');
4142
- const tracksDir = path.join(audioDir, 'tracks');
4143
- console.log(chalk.gray(` → ${commandsDir}`));
4144
- console.log(chalk.gray(` → ${hooksDir}`));
4145
- console.log(chalk.gray(` → ${audioDir}`));
4146
- console.log(chalk.gray(` → ${tracksDir}`));
4147
- await fs.mkdir(commandsDir, { recursive: true });
4148
- await fs.mkdir(hooksDir, { recursive: true });
4149
- await fs.mkdir(tracksDir, { recursive: true });
4150
- console.log(chalk.green(' āœ“ Directories created!\n'));
4151
- } else {
4152
- spinner.succeed(chalk.green('.claude directory found!'));
4153
- console.log(chalk.gray(` Location: ${claudeDir}\n`));
4154
-
4155
- // Ensure audio/tracks directory exists even if .claude already exists
4156
- const audioDir = path.join(claudeDir, 'audio');
4157
- const tracksDir = path.join(audioDir, 'tracks');
4158
- await fs.mkdir(tracksDir, { recursive: true });
4159
- }
4160
-
4161
- // Copy all files using helper functions
4162
- const commandResult = await copyCommandFiles(targetDir, spinner);
4163
- const hookResult = await copyHookFiles(targetDir, spinner);
4164
- const personalityResult = await copyPersonalityFiles(targetDir, spinner);
4165
- const pluginFileCount = await copyPluginFiles(targetDir, spinner);
4166
- const bmadConfigFileCount = await copyBmadConfigFiles(targetDir, spinner);
4167
- const backgroundMusicResult = await copyBackgroundMusicFiles(targetDir, spinner);
4168
- const configFileCount = await copyConfigFiles(targetDir, spinner);
4169
-
4170
- // Configure hooks and manifests
4171
- await configureSessionStartHook(targetDir, spinner);
4172
- await installPluginManifest(targetDir, spinner);
4908
+ const audioDir = path.join(claudeDir, 'audio');
4909
+ const tracksDir = path.join(audioDir, 'tracks');
4910
+ await fs.mkdir(commandsDir, { recursive: true });
4911
+ await fs.mkdir(hooksDir, { recursive: true });
4912
+ await fs.mkdir(tracksDir, { recursive: true });
4913
+
4914
+ // Copy all files silently
4915
+ await copyCommandFiles(targetDir, silentSpinner);
4916
+ await copyHookFiles(targetDir, silentSpinner);
4917
+ await copyPersonalityFiles(targetDir, silentSpinner);
4918
+ await copyPluginFiles(targetDir, silentSpinner);
4919
+ await copyBmadConfigFiles(targetDir, silentSpinner);
4920
+ await copyBackgroundMusicFiles(targetDir, silentSpinner);
4921
+ await copyConfigFiles(targetDir, silentSpinner);
4922
+ await configureSessionStartHook(targetDir, silentSpinner);
4923
+ await installPluginManifest(targetDir, silentSpinner);
4924
+ await ensureGitRepo(targetDir, silentSpinner);
4173
4925
 
4174
4926
  // Save provider configuration
4175
- spinner.start('Saving provider configuration...');
4176
4927
  const providerConfigPath = path.join(claudeDir, 'tts-provider.txt');
4177
4928
  await fs.writeFile(providerConfigPath, selectedProvider);
4178
4929
 
@@ -4188,29 +4939,20 @@ async function install(options = {}) {
4188
4939
 
4189
4940
  // Set up receiver script if in receiver mode (Termux)
4190
4941
  if (userConfig.isReceiver) {
4191
- spinner.start('Setting up receiver script...');
4192
-
4193
4942
  const receiverDir = path.join(process.env.HOME || process.env.USERPROFILE, '.agentvibes');
4194
4943
  await fs.mkdir(receiverDir, { recursive: true, mode: 0o700 });
4195
-
4196
4944
  const receiverScriptPath = path.join(receiverDir, 'receiver.sh');
4197
4945
  const templatePath = path.join(__dirname, '..', 'templates', 'agentvibes-receiver.sh');
4198
-
4199
4946
  try {
4200
4947
  const templateContent = await fs.readFile(templatePath, 'utf8');
4201
4948
  await fs.writeFile(receiverScriptPath, templateContent, { mode: 0o755 });
4202
- spinner.succeed(chalk.green('Receiver script installed!'));
4203
- console.log(chalk.gray(` Location: ${receiverScriptPath}\n`));
4204
- } catch (error) {
4205
- spinner.warn(chalk.yellow('Could not install receiver script'));
4206
- console.log(chalk.gray(` Error: ${error.message}\n`));
4949
+ } catch {
4950
+ // Receiver script install failed — non-fatal
4207
4951
  }
4208
4952
  }
4209
4953
 
4210
- // Save setup guide for SSH-remote installations
4954
+ // Save setup guide for SSH-remote installations (file only, no terminal output)
4211
4955
  if (selectedProvider === 'termux-ssh' || selectedProvider === 'ssh-pulseaudio') {
4212
- spinner.start('Saving setup guide...');
4213
-
4214
4956
  const agentvibesDir = path.join(process.env.HOME || process.env.USERPROFILE, '.agentvibes');
4215
4957
  await fs.mkdir(agentvibesDir, { recursive: true, mode: 0o700 });
4216
4958
 
@@ -4271,397 +5013,100 @@ Troubleshooting:
4271
5013
  - Verify AgentVibes on receiver: ssh ${userConfig.sshHost || 'phone'} which agentvibes
4272
5014
  - Test receiver script: ssh ${userConfig.sshHost || 'phone'} ~/.agentvibes/receiver.sh "Test"
4273
5015
  `;
4274
-
4275
5016
  try {
4276
5017
  await fs.writeFile(setupGuidePath, setupGuideContent);
4277
- spinner.succeed(chalk.green('Setup guide saved!'));
4278
- console.log(chalk.gray(` Location: ${setupGuidePath}\n`));
4279
- } catch (error) {
4280
- spinner.warn(chalk.yellow('Could not save setup guide'));
4281
- console.log(chalk.gray(` Error: ${error.message}\n`));
5018
+ } catch {
5019
+ // Setup guide write failed — non-fatal
4282
5020
  }
4283
5021
  }
4284
5022
 
4285
- // Set default voice based on user selection or provider defaults
5023
+ // Set default voice
4286
5024
  const voiceConfigPath = path.join(claudeDir, 'tts-voice.txt');
4287
5025
  let defaultVoice = userConfig.defaultVoice;
4288
-
4289
- // Fallback to defaults if voice wasn't selected
4290
5026
  if (!defaultVoice) {
4291
5027
  switch (selectedProvider) {
4292
- case 'piper':
4293
- defaultVoice = 'en_US-ryan-high';
4294
- break;
4295
- case 'macos':
4296
- defaultVoice = 'Samantha';
4297
- break;
4298
- case 'windows-piper':
4299
- defaultVoice = 'en_US-ryan-high';
4300
- break;
4301
- case 'windows-sapi':
4302
- defaultVoice = 'Microsoft David Desktop';
4303
- break;
4304
- case 'soprano':
4305
- defaultVoice = 'soprano-default';
4306
- break;
4307
- case 'termux-ssh':
4308
- // Android TTS voices are managed in Android settings, not here
4309
- defaultVoice = 'android-system-default';
4310
- break;
4311
- default:
4312
- defaultVoice = 'Samantha';
4313
- break;
5028
+ case 'piper': defaultVoice = 'en_US-ryan-high'; break;
5029
+ case 'macos': defaultVoice = 'Samantha'; break;
5030
+ case 'windows-piper': defaultVoice = 'en_US-ryan-high'; break;
5031
+ case 'windows-sapi': defaultVoice = 'Microsoft David Desktop'; break;
5032
+ case 'soprano': defaultVoice = 'soprano-default'; break;
5033
+ case 'termux-ssh': defaultVoice = 'android-system-default'; break;
5034
+ default: defaultVoice = 'Samantha'; break;
4314
5035
  }
4315
5036
  }
4316
5037
  await fs.writeFile(voiceConfigPath, defaultVoice);
4317
5038
 
4318
- spinner.succeed();
4319
-
4320
5039
  // Detect and migrate old configuration
4321
- await detectAndMigrateOldConfig(targetDir, spinner);
4322
-
4323
- // Snapshot existing Piper voices BEFORE installation (for proper summary display)
4324
- let preExistingVoices = [];
4325
- if (selectedProvider === 'piper') {
4326
- const piperVoicesDir = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'piper-voices');
4327
- try {
4328
- if (fsSync.existsSync(piperVoicesDir)) {
4329
- const files = fsSync.readdirSync(piperVoicesDir);
4330
- preExistingVoices = files
4331
- .filter(f => f.endsWith('.onnx'))
4332
- .map(f => f.replace('.onnx', ''));
4333
- }
4334
- } catch {
4335
- // Ignore errors
4336
- }
4337
- }
5040
+ await detectAndMigrateOldConfig(targetDir, silentSpinner);
4338
5041
 
4339
5042
  // Auto-install Piper if selected
4340
5043
  if (selectedProvider === 'piper') {
5044
+ spinner.text = 'Installing Piper TTS...';
4341
5045
  await checkAndInstallPiper(targetDir, options);
4342
5046
  } else if (selectedProvider === 'windows-piper') {
5047
+ spinner.text = 'Installing Piper TTS for Windows...';
4343
5048
  await checkAndInstallPiperWindows(targetDir, options);
4344
- } else if (selectedProvider === 'soprano') {
4345
- console.log(chalk.magenta('⚔ Soprano TTS selected'));
4346
- console.log(chalk.gray(' Install: pip install soprano-tts'));
4347
- console.log(chalk.gray(' GPU: pip install soprano-tts[lmdeploy]'));
4348
- console.log(chalk.gray(' Start: soprano-webui\n'));
4349
- } else if (selectedProvider === 'windows-sapi') {
4350
- console.log(chalk.green('āœ“ Windows SAPI provider selected - no additional setup needed\n'));
4351
5049
  }
4352
5050
 
4353
- // Apply background music configuration from userConfig
4354
- if (backgroundMusicResult.count > 0) {
4355
- const configDir = path.join(claudeDir, 'config');
4356
- await fs.mkdir(configDir, { recursive: true });
4357
-
4358
- if (userConfig.backgroundMusic.enabled) {
4359
- // Write enabled flag
4360
- const enabledFile = path.join(configDir, 'background-music-enabled.txt');
4361
- await fs.writeFile(enabledFile, 'true');
4362
-
4363
- // Update audio-effects.cfg with selected track
5051
+ // Apply background music configuration
5052
+ const configDir = path.join(claudeDir, 'config');
5053
+ await fs.mkdir(configDir, { recursive: true });
5054
+ if (userConfig.backgroundMusic?.enabled) {
5055
+ await fs.writeFile(path.join(configDir, 'background-music-enabled.txt'), 'true');
5056
+ try {
4364
5057
  const audioEffectsPath = path.join(configDir, 'audio-effects.cfg');
4365
5058
  let audioEffectsContent = await fs.readFile(audioEffectsPath, 'utf-8');
4366
-
4367
- // Update the default entry with selected track
4368
5059
  audioEffectsContent = audioEffectsContent.replace(
4369
5060
  /^default\|([^|]*)\|([^|]*)\|(.*)$/m,
4370
5061
  `default|$1|${userConfig.backgroundMusic.track}|$3`
4371
5062
  );
4372
-
4373
5063
  await fs.writeFile(audioEffectsPath, audioEffectsContent);
5064
+ } catch {
5065
+ // Audio effects config not yet available — non-fatal
4374
5066
  }
4375
5067
  }
4376
5068
 
4377
- // Apply reverb configuration from userConfig
4378
- const selectedReverb = userConfig.reverb;
4379
-
4380
- // Apply verbosity configuration from userConfig
4381
- const verbosityFile = path.join(claudeDir, 'tts-verbosity.txt');
4382
- await fs.writeFile(verbosityFile, userConfig.verbosity);
4383
-
4384
- // Apply personality configuration from userConfig
5069
+ // Apply verbosity, personality, pretext
5070
+ await fs.writeFile(path.join(claudeDir, 'tts-verbosity.txt'), userConfig.verbosity);
4385
5071
  if (userConfig.personality && userConfig.personality !== 'none') {
4386
- const personalityFile = path.join(claudeDir, 'tts-personality.txt');
4387
- await fs.writeFile(personalityFile, userConfig.personality);
4388
- }
4389
-
4390
- // Initialize piperVoicesBoxen outside the conditional for proper scoping
4391
- let piperVoicesBoxen = null;
4392
-
4393
- if (selectedProvider === 'macos') {
4394
- // macOS Say provider summary
4395
- console.log(chalk.white(` • Using macOS built-in Say command`));
4396
- console.log(chalk.white(` • System voices available (Samantha, Alex, etc.)`));
4397
- console.log(chalk.green(` • No API key needed āœ“`));
4398
- console.log(chalk.green(` • Zero setup required āœ“`));
4399
- } else {
4400
- // Check for installed Piper voices
4401
- const piperVoicesDir = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'piper-voices');
4402
- let installedVoices = [];
4403
- let missingVoices = [];
4404
-
4405
- const commonVoices = [
4406
- 'en_US-lessac-medium',
4407
- 'en_US-amy-medium',
4408
- 'en_US-joe-medium',
4409
- 'en_US-ryan-high',
4410
- 'en_US-libritts-high',
4411
- '16Speakers'
4412
- ];
4413
-
4414
- try {
4415
- if (fsSync.existsSync(piperVoicesDir)) {
4416
- const files = fsSync.readdirSync(piperVoicesDir);
4417
- installedVoices = files
4418
- .filter(f => f.endsWith('.onnx'))
4419
- .map(f => {
4420
- const voiceName = f.replace('.onnx', '');
4421
- const voicePath = path.join(piperVoicesDir, f);
4422
- try {
4423
- const stats = fsSync.statSync(voicePath);
4424
- const sizeMB = (stats.size / 1024 / 1024).toFixed(1);
4425
- return { name: voiceName, path: voicePath, size: `${sizeMB}M` };
4426
- } catch (statErr) {
4427
- // Skip files that can't be read (broken symlinks, etc)
4428
- return null;
4429
- }
4430
- })
4431
- .filter(v => v !== null);
4432
-
4433
- // Check which common voices are missing
4434
- for (const voice of commonVoices) {
4435
- if (!installedVoices.some(v => v.name === voice)) {
4436
- missingVoices.push(voice);
4437
- }
4438
- }
4439
- } else {
4440
- missingVoices = commonVoices;
4441
- }
4442
- } catch (err) {
4443
- // On error, show default message
4444
- installedVoices = [];
4445
- missingVoices = commonVoices;
4446
- }
4447
-
4448
- // Create Piper voices boxen (only if newly installed)
4449
- if (installedVoices.length > 0) {
4450
- // Separate newly installed from pre-existing voices
4451
- const newlyInstalled = installedVoices.filter(v => !preExistingVoices.includes(v.name));
4452
- const alreadyInstalled = installedVoices.filter(v => preExistingVoices.includes(v.name));
4453
-
4454
- // Only create boxen if there are newly installed voices
4455
- if (newlyInstalled.length > 0) {
4456
- let content = chalk.bold.green(`${newlyInstalled.length} Newly Installed\n\n`);
4457
- newlyInstalled.forEach(voice => {
4458
- content += chalk.green(`āœ“ ${voice.name}`) + chalk.gray(` (${voice.size})\n`);
4459
- content += chalk.dim(` ${voice.path}\n`);
4460
- });
4461
-
4462
- if (alreadyInstalled.length > 0) {
4463
- content += '\n' + chalk.gray('─'.repeat(60)) + '\n\n';
4464
- content += chalk.bold.cyan(`${alreadyInstalled.length} Already Installed\n\n`);
4465
- alreadyInstalled.forEach(voice => {
4466
- content += chalk.cyan(`āœ“ ${voice.name}`) + chalk.gray(` (${voice.size})\n`);
4467
- content += chalk.dim(` ${voice.path}\n`);
4468
- });
4469
- }
4470
-
4471
- // Add additional info at the bottom of boxen
4472
- content += '\n' + chalk.gray('─'.repeat(60)) + '\n\n';
4473
- content += chalk.white('• 18 languages supported\n');
4474
- content += chalk.green('• No API key needed āœ“');
4475
-
4476
- piperVoicesBoxen = boxen(content.trim(), {
4477
- padding: 1,
4478
- margin: 1,
4479
- borderStyle: 'round',
4480
- borderColor: 'cyan',
4481
- title: chalk.bold(`šŸŽ¤ Piper Voices (${installedVoices.length} total)`),
4482
- titleAlignment: 'center'
4483
- });
4484
- }
4485
- }
4486
-
4487
- if (missingVoices.length > 0) {
4488
- console.log(chalk.yellow(` • ${missingVoices.length} voices to download:`));
4489
- missingVoices.forEach(voice => {
4490
- console.log(chalk.gray(` → ${voice}`));
4491
- });
4492
- }
4493
-
4494
- if (installedVoices.length === 0 && missingVoices.length === 0) {
4495
- console.log(chalk.white(` • 50+ Piper neural voices available (free!)`));
4496
- }
4497
- }
4498
- console.log('');
4499
-
4500
- // Collect all boxens for pagination
4501
- const pages = [];
4502
- if (commandResult.boxen) {
4503
- pages.push({ title: 'Summary: Slash Commands', content: commandResult.boxen });
4504
- }
4505
- if (backgroundMusicResult.boxen) {
4506
- pages.push({ title: 'Summary: Background Music', content: backgroundMusicResult.boxen });
4507
- }
4508
- if (hookResult.boxen) {
4509
- pages.push({ title: 'Summary: TTS Scripts', content: hookResult.boxen });
4510
- }
4511
- if (personalityResult.boxen) {
4512
- pages.push({ title: 'Summary: Personalities', content: personalityResult.boxen });
5072
+ await fs.writeFile(path.join(claudeDir, 'tts-personality.txt'), userConfig.personality);
4513
5073
  }
4514
- if (piperVoicesBoxen) {
4515
- pages.push({ title: 'Summary: Piper Voices', content: piperVoicesBoxen });
4516
- }
4517
-
4518
- // Recent Changes already shown on page 2 after welcome banner - no need to show again
4519
-
4520
- // Handle MCP configuration - offer to create .mcp.json if not exists
4521
- await handleMcpConfiguration(targetDir, options);
4522
-
4523
- // Create default BMAD voice assignments (works even if BMAD not installed yet)
4524
- await createDefaultBmadVoiceAssignmentsProactive(targetDir);
4525
-
4526
- // Handle BMAD integration
4527
- const bmadDetection = await handleBmadIntegration(targetDir, options);
4528
- const bmadDetected = bmadDetection.installed;
4529
-
4530
- if (bmadDetected) {
4531
- const versionLabel = bmadDetection.version === 6
4532
- ? `v${bmadDetection.detailedVersion}`
4533
- : 'v4';
4534
-
4535
- const bmadContent =
4536
- chalk.green.bold(`šŸŽ‰ BMAD-METHODā„¢ ${versionLabel} Detected!\n\n`) +
4537
- chalk.white.bold('We detected you ALREADY have the BMAD-METHODā„¢\n') +
4538
- chalk.white.bold('The Universal AI Agent Framework installed!\n\n') +
4539
- chalk.cyan('✨ Try the Party Mode command:\n') +
4540
- chalk.yellow.bold(' /bmad:core:workflows:party-mode\n\n') +
4541
- chalk.gray('AgentVibes will assign a unique voice to each agent\n') +
4542
- chalk.gray('while they help you with your project!\n\n') +
4543
- chalk.cyan('Other Commands:\n') +
4544
- chalk.gray(' • /agent-vibes:bmad status - View voice assignments\n') +
4545
- chalk.gray(' • /agent-vibes:bmad set <agent> <voice> - Customize voices');
4546
-
4547
- pages.push({ title: 'BMAD Integration', content: bmadContent });
5074
+ if (userConfig.pretext && userConfig.pretext.trim()) {
5075
+ await fs.writeFile(path.join(configDir, 'tts-pretext.txt'), userConfig.pretext, { mode: 0o600 });
4548
5076
  } else {
4549
- const bmadRecommendBoxen = boxen(
4550
- chalk.cyan.bold('šŸ’” We also Recommend:\n\n') +
4551
- chalk.white.bold('BMAD-METHODā„¢: Universal AI Agent Framework\n\n') +
4552
- chalk.gray('AgentVibes auto-detects BMAD and assigns voices to agents!\n\n') +
4553
- chalk.cyan('https://github.com/bmad-code-org/BMAD-METHOD'),
4554
- {
4555
- padding: 1,
4556
- margin: 1,
4557
- borderStyle: 'round',
4558
- borderColor: 'cyan',
4559
- }
4560
- );
4561
-
4562
- pages.push({ title: 'Recommended Tools', content: bmadRecommendBoxen });
5077
+ try { await fs.unlink(path.join(configDir, 'tts-pretext.txt')); } catch { /* ok */ }
4563
5078
  }
4564
5079
 
4565
- // Apply reverb setting if not "off" (using dynamic import for ES modules)
5080
+ // Apply reverb setting
5081
+ const selectedReverb = userConfig.reverb;
4566
5082
  if (selectedReverb && selectedReverb !== 'off') {
4567
5083
  const effectsManagerPath = path.join(targetDir, '.claude', 'hooks', 'effects-manager.sh');
4568
- // Validate reverb value to prevent command injection
4569
5084
  const validReverb = ['light', 'medium', 'heavy', 'cathedral'];
4570
5085
  if (validReverb.includes(selectedReverb)) {
4571
5086
  try {
4572
- execFileSync('bash', [effectsManagerPath, 'set-reverb', selectedReverb, 'default'], {
4573
- stdio: 'pipe',
4574
- });
4575
- } catch (error) {
4576
- // Silent fail - will be shown in success message if needed
5087
+ execFileSync('bash', [effectsManagerPath, 'set-reverb', selectedReverb, 'default'], { stdio: 'pipe' });
5088
+ } catch {
5089
+ // Reverb setting failed — non-fatal
4577
5090
  }
4578
5091
  }
4579
5092
  }
4580
5093
 
4581
- // Success message as final page (no boxen) - Customize based on setup type
4582
- let successContent = chalk.green.bold('✨ Installation Complete! ✨\n\n');
4583
-
4584
- // Receiver mode success message (Termux/Phone)
4585
- if (userConfig.isReceiver) {
4586
- successContent +=
4587
- chalk.green('āœ“ Receiver mode configured!\n') +
4588
- chalk.green('āœ“ Receiver script: ~/.agentvibes/receiver.sh\n') +
4589
- chalk.green('āœ“ Provider: piper (local playback)\n\n') +
4590
- chalk.cyan('šŸ“‹ Next: On your server, set this device as target:\n') +
4591
- chalk.white(' echo "phone" > ~/.claude/ssh-remote-host.txt\n\n') +
4592
- chalk.gray('(Use your SSH hostname or Tailscale name)\n\n');
4593
- }
4594
- // SSH-Remote success message (Voiceless server)
4595
- else if (selectedProvider === 'termux-ssh' || selectedProvider === 'ssh-pulseaudio') {
4596
- successContent +=
4597
- chalk.green('āœ“ Provider configured: ssh-remote\n\n') +
4598
- chalk.cyan('šŸ“‹ Setup Instructions Saved:\n') +
4599
- chalk.white(' ~/.agentvibes/setup-guide.txt\n\n') +
4600
- chalk.cyan('Quick Start:\n') +
4601
- chalk.white('1. Install AgentVibes on target device\n') +
4602
- chalk.white('2. Configure SSH/Tailscale access\n') +
4603
- chalk.white('3. Test: agentvibes tts "Hello!"\n\n');
4604
- }
4605
- // Standard installation success message
4606
- else {
4607
- successContent +=
4608
- chalk.green('āœ… AgentVibes TTS is now active via SessionStart hook!\n') +
4609
- chalk.gray(' (No additional setup needed - TTS protocol auto-loads on every session)\n\n');
4610
- }
4611
-
4612
- // Common commands section for all installations
4613
- successContent +=
4614
- chalk.white('šŸŽ¤ Available Commands:\n\n') +
4615
- chalk.cyan(' /agent-vibes') + chalk.gray(' .................... Show all commands\n') +
4616
- chalk.cyan(' /agent-vibes:list') + chalk.gray(' ............... List available voices\n') +
4617
- chalk.cyan(' /agent-vibes:preview') + chalk.gray(' ............ Preview voice samples\n') +
4618
- chalk.cyan(' /agent-vibes:switch <name>') + chalk.gray(' ...... Change active voice\n') +
4619
- chalk.cyan(' /agent-vibes:replay [N]') + chalk.gray(' ......... Replay last audio\n') +
4620
- chalk.cyan(' /agent-vibes:whoami') + chalk.gray(' .............. Show current voice\n\n');
4621
-
4622
- if (!userConfig.isReceiver) {
4623
- successContent += chalk.yellow('šŸŽµ Try: ') + chalk.cyan('/agent-vibes:preview') + chalk.yellow(' to hear the voices!\n\n');
4624
- }
4625
-
4626
- successContent +=
4627
- chalk.gray('šŸ“¦ Repo: ') + chalk.cyan('https://github.com/paulpreibisch/AgentVibes\n') +
4628
- chalk.gray('šŸ“– Docs: ') + chalk.cyan('https://github.com/paulpreibisch/AgentVibes/blob/master/README.md');
4629
-
4630
- pages.push({ title: 'Installation Complete', content: successContent });
4631
-
4632
- // Show all pages with pagination navigation
4633
- const postInstallOffset = configPages + preInstallPages.length; // After config + pre-install pages
4634
- const actualTotalPages = configPages + preInstallPages.length + pages.length;
4635
-
4636
- await showPaginatedContent(pages, {
4637
- ...options,
4638
- continueLabel: 'āœ“ Installation Complete',
4639
- pageOffset: postInstallOffset,
4640
- totalPages: actualTotalPages
4641
- });
4642
-
4643
- // Final message after pagination
4644
- console.log(chalk.green.bold('\nāœ… AgentVibes is Ready!\n'));
4645
-
4646
- // Check for .mcp.json file
4647
- const mcpConfigPath = path.join(targetDir, '.mcp.json');
4648
- const hasMcpConfig = fsSync.existsSync(mcpConfigPath);
4649
-
4650
- if (hasMcpConfig) {
4651
- console.log(chalk.white(' Launch Claude Code with MCP:'));
4652
- console.log(chalk.cyan(' claude --mcp-config .mcp.json\n'));
4653
- } else {
4654
- console.log(chalk.white(' Start a new session to activate TTS.\n'));
4655
- }
5094
+ // MCP configuration and BMAD voice assignments (silent)
5095
+ await handleMcpConfiguration(targetDir, { ...options, yes: true });
5096
+ await createDefaultBmadVoiceAssignmentsProactive(targetDir);
5097
+ await handleBmadIntegration(targetDir, { ...options, yes: true });
4656
5098
 
4657
- console.log(chalk.white(' • /agent-vibes:list') + chalk.gray(' - See all available voices'));
4658
- console.log(chalk.white(' • /agent-vibes:switch <name>') + chalk.gray(' - Change your voice'));
4659
- console.log(chalk.white(' • /agent-vibes:personality <style>') + chalk.gray(' - Set personality\n'));
5099
+ spinner.succeed(chalk.green('AgentVibes installed successfully!'));
4660
5100
 
4661
- // Play welcome demo with harpsichord intro and reverb voice (opt-in only)
4662
- if (options.withAudio) {
4663
- await playWelcomeDemo(targetDir, spinner, options);
4664
- }
5101
+ // Clean final summary
5102
+ console.log('');
5103
+ console.log(chalk.green.bold(' āœ… Installation Complete'));
5104
+ console.log(chalk.gray(` Provider: ${selectedProvider}`));
5105
+ console.log(chalk.gray(` Location: ${targetDir}/.claude/`));
5106
+ console.log(chalk.gray(` Version: ${VERSION}`));
5107
+ console.log('');
5108
+ console.log(chalk.white(' Run ') + chalk.cyan('npx agentvibes') + chalk.white(' to open the console.'));
5109
+ console.log('');
4665
5110
 
4666
5111
  } catch (error) {
4667
5112
  spinner.fail('Installation failed!');
@@ -4680,7 +5125,6 @@ program
4680
5125
  .description('Install AgentVibes voice commands')
4681
5126
  .option('-d, --directory <path>', 'Installation directory (default: current directory)')
4682
5127
  .option('-y, --yes', 'Skip confirmation prompt (auto-confirm)')
4683
- .option('--with-audio', 'Play welcome demo audio after installation')
4684
5128
  .action(async (options) => {
4685
5129
  await install(options);
4686
5130
  });
@@ -4700,7 +5144,8 @@ program
4700
5144
  console.log(chalk.gray(` Update location: ${targetDir}/.claude/`));
4701
5145
  console.log(chalk.gray(` Package version: ${VERSION}`));
4702
5146
 
4703
- showReleaseInfo();
5147
+ const releaseInfo = getReleaseInfoBoxen();
5148
+ if (releaseInfo) console.log(releaseInfo);
4704
5149
 
4705
5150
  // Check if already installed
4706
5151
  const commandsDir = path.join(targetDir, '.claude', 'commands', 'agent-vibes');
@@ -5055,6 +5500,232 @@ program
5055
5500
  await resetBmadVoices(options);
5056
5501
  });
5057
5502
 
5503
+ // Story 1.4: Config Command - Post-install configuration
5504
+ program
5505
+ .command('config <setting>')
5506
+ .description('Configure AgentVibes settings after installation')
5507
+ .usage('intro-text [--no-default]')
5508
+ .action(async (setting, options) => {
5509
+ if (setting === 'intro-text' || setting === 'pretext') {
5510
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
5511
+ const claudeDir = path.join(homeDir, '.claude');
5512
+ const pretextFile = path.join(claudeDir, 'config', 'tts-pretext.txt');
5513
+
5514
+ // Read current pretext if it exists
5515
+ let currentPretext = '';
5516
+ try {
5517
+ if (fsSync.existsSync(pretextFile)) {
5518
+ currentPretext = fsSync.readFileSync(pretextFile, 'utf-8').trim();
5519
+ }
5520
+ } catch (err) {
5521
+ // Ignore read errors
5522
+ }
5523
+
5524
+ // Prompt for new intro text
5525
+ const { newPretext } = await inquirer.prompt([
5526
+ {
5527
+ type: 'input',
5528
+ name: 'newPretext',
5529
+ message: chalk.yellow('Enter new intro text (max 50 chars):'),
5530
+ default: currentPretext || '(none)',
5531
+ validate: (input) => {
5532
+ if (input === '(none)') return true;
5533
+ if (input.length > 50) return 'Max 50 characters';
5534
+ if (input.includes('\n') || input.includes('\r')) return 'No newlines allowed';
5535
+ return true;
5536
+ }
5537
+ }
5538
+ ]);
5539
+
5540
+ // Handle the response
5541
+ if (newPretext === '(none)' || newPretext === '') {
5542
+ // Remove pretext
5543
+ try {
5544
+ await fs.unlink(pretextFile);
5545
+ console.log(chalk.green('āœ“ Intro text cleared\n'));
5546
+ } catch (err) {
5547
+ // File doesn't exist - that's fine
5548
+ console.log(chalk.green('āœ“ No intro text configured\n'));
5549
+ }
5550
+ } else {
5551
+ // Save new pretext
5552
+ try {
5553
+ const configDir = path.join(claudeDir, 'config');
5554
+ await fs.mkdir(configDir, { recursive: true });
5555
+ await fs.writeFile(pretextFile, newPretext, { mode: 0o600 });
5556
+ console.log(chalk.green(`āœ“ Intro text updated: "${newPretext}"\n`));
5557
+ console.log(chalk.cyan('Preview: ') + chalk.gray(`"${newPretext}" This is a sample response\n`));
5558
+ } catch (err) {
5559
+ console.log(chalk.red(`āŒ Error saving intro text: ${err.message}\n`));
5560
+ process.exit(1);
5561
+ }
5562
+ }
5563
+ } else if (setting === 'music') {
5564
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
5565
+ const claudeDir = path.join(homeDir, '.claude');
5566
+ const musicTracksDir = path.join(claudeDir, 'audio', 'custom-music', 'tracks');
5567
+ const musicConfigFile = path.join(claudeDir, 'config', 'background-music.txt');
5568
+ const musicEnabledFile = path.join(claudeDir, 'config', 'background-music-enabled.txt');
5569
+
5570
+ console.log(boxen(
5571
+ chalk.cyan.bold('šŸŽµ Background Music Configuration\n\n') +
5572
+ chalk.white('Manage your custom background music settings.'),
5573
+ { padding: 1, borderColor: 'cyan', borderStyle: 'round' }
5574
+ ));
5575
+
5576
+ // Read current music setting
5577
+ let currentMusic = null;
5578
+ let musicEnabled = false;
5579
+
5580
+ try {
5581
+ if (fsSync.existsSync(musicEnabledFile)) {
5582
+ const enabled = fsSync.readFileSync(musicEnabledFile, 'utf-8').trim();
5583
+ musicEnabled = enabled === 'true' || enabled === '1';
5584
+ }
5585
+ if (fsSync.existsSync(musicConfigFile)) {
5586
+ currentMusic = fsSync.readFileSync(musicConfigFile, 'utf-8').trim();
5587
+ }
5588
+ } catch (err) {
5589
+ // Ignore read errors
5590
+ }
5591
+
5592
+ // Display current setting
5593
+ console.log(chalk.gray('\nCurrent Settings:'));
5594
+ console.log(chalk.gray(' Background Music: ') + (musicEnabled ? chalk.green('Enabled') : chalk.yellow('Disabled')));
5595
+
5596
+ if (currentMusic && musicEnabled) {
5597
+ const isCustom = currentMusic.startsWith('custom-') || !currentMusic.includes('agentvibes_');
5598
+ if (isCustom) {
5599
+ console.log(chalk.gray(' Track: ') + chalk.cyan(currentMusic) + chalk.yellow(' (Custom)'));
5600
+ } else {
5601
+ console.log(chalk.gray(' Track: ') + chalk.white(currentMusic) + chalk.gray(' (Default)'));
5602
+ }
5603
+ } else if (!musicEnabled) {
5604
+ console.log(chalk.gray(' Track: ') + chalk.gray('(None - music disabled)'));
5605
+ }
5606
+ console.log('');
5607
+
5608
+ // Show menu
5609
+ const { action } = await inquirer.prompt([{
5610
+ type: 'list',
5611
+ name: 'action',
5612
+ message: chalk.yellow('What would you like to do?'),
5613
+ choices: [
5614
+ { name: 'šŸŽµ Change custom music file', value: 'change' },
5615
+ { name: 'šŸ—‘ļø Remove custom music (use defaults)', value: 'remove' },
5616
+ { name: 'šŸ”„ Reset to factory defaults', value: 'reset' },
5617
+ { name: musicEnabled ? 'šŸ”‡ Disable background music' : 'šŸ”Š Enable background music', value: 'toggle' },
5618
+ new inquirer.Separator(),
5619
+ { name: '← Back', value: 'back' }
5620
+ ]
5621
+ }]);
5622
+
5623
+ if (action === 'change') {
5624
+ // Change music - reuse promptForCustomMusic from Story 4.5
5625
+ console.log('');
5626
+ const result = await promptForCustomMusic(claudeDir);
5627
+
5628
+ if (result.success && result.filename) {
5629
+ // Update config to point to custom music
5630
+ const configDir = path.join(claudeDir, 'config');
5631
+ await fs.mkdir(configDir, { recursive: true });
5632
+ await fs.writeFile(musicConfigFile, result.filename, { mode: 0o600 });
5633
+
5634
+ // Ensure music is enabled
5635
+ await fs.writeFile(musicEnabledFile, 'true', { mode: 0o644 });
5636
+
5637
+ console.log(chalk.green(`\nāœ“ Background music updated to: ${result.filename}`));
5638
+ console.log(chalk.gray(' Changes take effect immediately\n'));
5639
+ } else if (result.error) {
5640
+ console.log(chalk.yellow(`\nāš ļø ${result.error}`));
5641
+ console.log(chalk.gray(' Keeping previous setting\n'));
5642
+ } else {
5643
+ console.log(chalk.gray('\n No changes made\n'));
5644
+ }
5645
+
5646
+ } else if (action === 'remove') {
5647
+ // Remove custom music - keep defaults
5648
+ const customMusicFiles = fsSync.existsSync(musicTracksDir) ?
5649
+ fsSync.readdirSync(musicTracksDir) : [];
5650
+
5651
+ if (customMusicFiles.length === 0) {
5652
+ console.log(chalk.yellow('\nāš ļø No custom music files found\n'));
5653
+ } else {
5654
+ const { confirm } = await inquirer.prompt([{
5655
+ type: 'confirm',
5656
+ name: 'confirm',
5657
+ message: 'Remove all custom music files?',
5658
+ default: false
5659
+ }]);
5660
+
5661
+ if (confirm) {
5662
+ try {
5663
+ // Remove custom music files
5664
+ for (const file of customMusicFiles) {
5665
+ await fs.unlink(path.join(musicTracksDir, file));
5666
+ }
5667
+
5668
+ // Reset to default music
5669
+ await fs.writeFile(musicConfigFile, 'agentvibes_soft_flamenco_loop.mp3', { mode: 0o600 });
5670
+
5671
+ console.log(chalk.green('\nāœ“ Custom music removed, reverted to defaults\n'));
5672
+ } catch (err) {
5673
+ console.log(chalk.red(`\nāŒ Error removing custom music: ${err.message}\n`));
5674
+ }
5675
+ } else {
5676
+ console.log(chalk.gray('\n Cancelled\n'));
5677
+ }
5678
+ }
5679
+
5680
+ } else if (action === 'reset') {
5681
+ // Reset to factory defaults
5682
+ const { confirm } = await inquirer.prompt([{
5683
+ type: 'confirm',
5684
+ name: 'confirm',
5685
+ message: 'Reset all music settings to factory defaults?',
5686
+ default: false
5687
+ }]);
5688
+
5689
+ if (confirm) {
5690
+ try {
5691
+ // Reset to default track
5692
+ await fs.writeFile(musicConfigFile, 'agentvibes_soft_flamenco_loop.mp3', { mode: 0o600 });
5693
+
5694
+ // Enable music
5695
+ await fs.writeFile(musicEnabledFile, 'true', { mode: 0o644 });
5696
+
5697
+ console.log(chalk.green('\nāœ“ Reset to factory defaults'));
5698
+ console.log(chalk.gray(' Track: Soft Flamenco (Spanish guitar)'));
5699
+ console.log(chalk.gray(' Status: Enabled\n'));
5700
+ } catch (err) {
5701
+ console.log(chalk.red(`\nāŒ Error resetting settings: ${err.message}\n`));
5702
+ }
5703
+ } else {
5704
+ console.log(chalk.gray('\n Cancelled\n'));
5705
+ }
5706
+
5707
+ } else if (action === 'toggle') {
5708
+ // Toggle music on/off
5709
+ try {
5710
+ const newState = !musicEnabled;
5711
+ await fs.writeFile(musicEnabledFile, newState ? 'true' : 'false', { mode: 0o644 });
5712
+
5713
+ console.log(chalk.green(`\nāœ“ Background music ${newState ? 'enabled' : 'disabled'}\n`));
5714
+ } catch (err) {
5715
+ console.log(chalk.red(`\nāŒ Error toggling music: ${err.message}\n`));
5716
+ }
5717
+
5718
+ } else if (action === 'back') {
5719
+ console.log(chalk.gray('\n No changes made\n'));
5720
+ }
5721
+
5722
+ } else {
5723
+ console.log(chalk.red(`āŒ Unknown setting: ${setting}`));
5724
+ console.log(chalk.gray('Available settings: intro-text, music\n'));
5725
+ process.exit(1);
5726
+ }
5727
+ });
5728
+
5058
5729
  // BMAD PR Testing Command
5059
5730
  program
5060
5731
  .command('test-bmad-pr [pr-number]')
@@ -5096,5 +5767,11 @@ if (__entryFile === __argvFile) {
5096
5767
  }
5097
5768
  /* c8 ignore stop */
5098
5769
 
5099
- // Export functions for testing
5100
- export { isTermux, isNativeWindows, detectAndNotifyTermux };
5770
+ // Export functions for testing and TUI installer
5771
+ export {
5772
+ isTermux, isNativeWindows, detectAndNotifyTermux,
5773
+ copyCommandFiles, copyHookFiles, copyPersonalityFiles,
5774
+ copyPluginFiles, copyBmadConfigFiles, copyBackgroundMusicFiles,
5775
+ copyConfigFiles, configureSessionStartHook, ensureGitRepo,
5776
+ installPluginManifest, checkAndInstallPiper,
5777
+ };