configuration_management.pyā¢12.7 kB
"""Configuration management tools for voice-mode."""
import os
import re
from pathlib import Path
from typing import Dict, Optional, List
from voice_mode.server import mcp
from voice_mode.config import BASE_DIR, reload_configuration, find_voicemode_env_files
import logging
logger = logging.getLogger("voicemode")
# Configuration file path (user-level only for security)
USER_CONFIG_PATH = Path.home() / ".voicemode" / "voicemode.env"
# Legacy path for backwards compatibility
LEGACY_CONFIG_PATH = Path.home() / ".voicemode" / ".voicemode.env"
def parse_env_file(file_path: Path) -> Dict[str, str]:
"""Parse an environment file and return a dictionary of key-value pairs."""
config = {}
if not file_path.exists():
return config
with open(file_path, 'r') as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Parse KEY=VALUE format
match = re.match(r'^([A-Z_]+)=(.*)$', line)
if match:
key, value = match.groups()
# Remove quotes if present
value = value.strip('"').strip("'")
config[key] = value
return config
def write_env_file(file_path: Path, config: Dict[str, str], preserve_comments: bool = True):
"""Write configuration to an environment file."""
# Read existing file to preserve comments and structure
existing_lines = []
existing_keys = set()
if file_path.exists() and preserve_comments:
with open(file_path, 'r') as f:
for line in f:
stripped = line.strip()
if stripped and not stripped.startswith('#'):
match = re.match(r'^([A-Z_]+)=', stripped)
if match:
key = match.group(1)
existing_keys.add(key)
if key in config:
# Replace with new value
existing_lines.append(f"{key}={config[key]}\n")
else:
# Keep existing line
existing_lines.append(line)
else:
existing_lines.append(line)
else:
# Keep comments and empty lines
existing_lines.append(line)
# Add new keys that weren't in the file
new_keys = set(config.keys()) - existing_keys
if new_keys and existing_lines:
# Add a newline before new entries if file has content
if existing_lines and not existing_lines[-1].strip() == '':
existing_lines.append('\n')
# Group new keys by category
whisper_keys = sorted([k for k in new_keys if k.startswith('VOICEMODE_WHISPER_')])
kokoro_keys = sorted([k for k in new_keys if k.startswith('VOICEMODE_KOKORO_')])
other_keys = sorted([k for k in new_keys if not k.startswith('VOICEMODE_WHISPER_') and not k.startswith('VOICEMODE_KOKORO_')])
if whisper_keys:
existing_lines.append("# Whisper Configuration\n")
for key in whisper_keys:
existing_lines.append(f"{key}={config[key]}\n")
existing_lines.append('\n')
if kokoro_keys:
existing_lines.append("# Kokoro Configuration\n")
for key in kokoro_keys:
existing_lines.append(f"{key}={config[key]}\n")
existing_lines.append('\n')
if other_keys:
existing_lines.append("# Additional Configuration\n")
for key in other_keys:
existing_lines.append(f"{key}={config[key]}\n")
# Write the file
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'w') as f:
f.writelines(existing_lines if existing_lines else [f"{k}={v}\n" for k, v in sorted(config.items())])
# Set appropriate permissions (readable/writable by owner only)
os.chmod(file_path, 0o600)
@mcp.tool()
async def update_config(key: str, value: str) -> str:
"""Update a configuration value in the voicemode.env file.
Args:
key: The configuration key to update (e.g., 'VOICEMODE_VOICES')
value: The new value for the configuration
Returns:
Confirmation message with the updated configuration
"""
# Validate key format
if not re.match(r'^[A-Z_]+$', key):
return f"ā Invalid key format: {key}. Keys must be uppercase with underscores only."
# Use user config path, check for legacy if new doesn't exist
config_path = USER_CONFIG_PATH
if not config_path.exists() and LEGACY_CONFIG_PATH.exists():
config_path = LEGACY_CONFIG_PATH
logger.warning(f"Using deprecated .voicemode.env - please rename to voicemode.env")
try:
# Read existing configuration
config = parse_env_file(config_path)
# Store old value for reporting
old_value = config.get(key, "[not set]")
# Update the configuration
config[key] = value
# Write back to file
write_env_file(config_path, config)
# Report the change
logger.info(f"Updated {key} in {config_path}")
return f"""ā
Configuration updated successfully!
File: {config_path}
Key: {key}
Old Value: {old_value}
New Value: {value}
Note: You may need to restart services or reload the configuration for changes to take effect."""
except Exception as e:
logger.error(f"Failed to update configuration: {e}")
return f"ā Failed to update configuration: {str(e)}"
@mcp.tool()
async def list_config_keys() -> str:
"""List all available configuration keys with their descriptions.
Returns:
A formatted list of all VOICEMODE_* configuration keys and their purposes
"""
config_keys = [
("Core Configuration", [
("VOICEMODE_BASE_DIR", "Base directory for all voicemode data (default: ~/.voicemode)"),
("VOICEMODE_MODELS_DIR", "Directory for all models (default: $VOICEMODE_BASE_DIR/models)"),
("VOICEMODE_DEBUG", "Enable debug mode (true/false)"),
("VOICEMODE_SAVE_ALL", "Save all audio and transcriptions (true/false)"),
("VOICEMODE_SAVE_AUDIO", "Save audio files (true/false)"),
("VOICEMODE_SAVE_TRANSCRIPTIONS", "Save transcription files (true/false)"),
("VOICEMODE_AUDIO_FEEDBACK", "Enable audio feedback (true/false)"),
]),
("Provider Configuration", [
("VOICEMODE_TTS_BASE_URLS", "Comma-separated list of TTS endpoints"),
("VOICEMODE_STT_BASE_URLS", "Comma-separated list of STT endpoints"),
("VOICEMODE_VOICES", "Comma-separated list of preferred voices"),
("VOICEMODE_TTS_MODELS", "Comma-separated list of preferred models"),
("VOICEMODE_PREFER_LOCAL", "Prefer local providers over cloud (true/false)"),
("VOICEMODE_ALWAYS_TRY_LOCAL", "Always attempt local providers (true/false)"),
("VOICEMODE_AUTO_START_KOKORO", "Auto-start Kokoro service (true/false)"),
]),
("Whisper Configuration", [
("VOICEMODE_WHISPER_MODEL", "Whisper model to use (e.g., large-v2)"),
("VOICEMODE_WHISPER_PORT", "Whisper server port (default: 2022)"),
("VOICEMODE_WHISPER_LANGUAGE", "Language for transcription (default: auto)"),
("VOICEMODE_WHISPER_MODEL_PATH", "Path to Whisper models"),
]),
("Kokoro Configuration", [
("VOICEMODE_KOKORO_PORT", "Kokoro server port (default: 8880)"),
("VOICEMODE_KOKORO_MODELS_DIR", "Directory for Kokoro models"),
("VOICEMODE_KOKORO_CACHE_DIR", "Directory for Kokoro cache"),
("VOICEMODE_KOKORO_DEFAULT_VOICE", "Default Kokoro voice (e.g., af_sky)"),
]),
("API Keys", [
("OPENAI_API_KEY", "OpenAI API key for cloud TTS/STT"),
("LIVEKIT_URL", "LiveKit server URL (default: ws://127.0.0.1:7880)"),
("LIVEKIT_API_KEY", "LiveKit API key"),
("LIVEKIT_API_SECRET", "LiveKit API secret"),
]),
]
lines = ["Available Configuration Keys", "=" * 50, ""]
for category, keys in config_keys:
lines.append(f"{category}:")
lines.append("-" * len(category))
for key, description in keys:
lines.append(f" {key}")
lines.append(f" {description}")
lines.append("")
lines.append("š” Usage: update_config(key='VOICEMODE_VOICES', value='af_sky,nova')")
return "\n".join(lines)
@mcp.tool()
async def config_reload() -> str:
"""Reload configuration from .voicemode.env files and clear all caches.
This tool reloads configuration from:
1. Global ~/.voicemode/voicemode.env file
2. Project-specific .voicemode.env files (searched up directory tree)
3. Environment variables (highest priority)
Returns:
Status message showing which files were loaded and any changes
"""
try:
# Get config files before reload
old_files = find_voicemode_env_files()
# Reload configuration
reload_configuration()
# Get config files after reload
new_files = find_voicemode_env_files()
lines = ["ā
Configuration reloaded successfully!", ""]
if new_files:
lines.append("š Configuration files loaded (in order):")
for i, config_file in enumerate(new_files, 1):
lines.append(f" {i}. {config_file}")
else:
lines.append("š No configuration files found - using defaults")
lines.append("")
lines.append("š All caches have been cleared")
lines.append("š Voice preferences and provider settings updated")
logger.info(f"Configuration reloaded from {len(new_files)} files")
return "\n".join(lines)
except Exception as e:
logger.error(f"Failed to reload configuration: {e}")
return f"ā Failed to reload configuration: {str(e)}"
@mcp.tool()
async def show_config_files() -> str:
"""Show which .voicemode.env files are being used for configuration.
This shows the current configuration file discovery and loading order:
- Global configuration from ~/.voicemode/voicemode.env
- Project-specific configuration (searched up directory tree)
- Current working directory for context
Returns:
Formatted list of configuration files and their status
"""
try:
config_files = find_voicemode_env_files()
lines = ["š Voice Mode Configuration Files", "=" * 40, ""]
lines.append(f"šļø Current directory: {Path.cwd()}")
lines.append("")
if config_files:
lines.append("š Configuration files (loading order):")
lines.append("")
for i, config_file in enumerate(config_files, 1):
status = "ā
EXISTS" if config_file.exists() else "ā MISSING"
file_type = ""
if config_file.name == "voicemode.env" and config_file.parent.name == ".voicemode":
if config_file.parent == Path.home() / ".voicemode":
file_type = " (Global)"
else:
file_type = " (Project - in .voicemode dir)"
elif config_file.name == ".voicemode.env":
if config_file.parent == Path.cwd():
file_type = " (Project - current dir)"
else:
file_type = " (Project - parent dir)"
lines.append(f" {i}. {config_file}{file_type}")
lines.append(f" {status}")
lines.append("")
else:
lines.append("ā No configuration files found")
lines.append("")
lines.append("š” Tip: Create ~/.voicemode/voicemode.env for global configuration")
lines.append("š” Tip: Create .voicemode.env in project directories for project-specific settings")
lines.append("")
lines.append("š Use reload_config() to reload after making changes")
return "\n".join(lines)
except Exception as e:
logger.error(f"Failed to show config files: {e}")
return f"ā Failed to show config files: {str(e)}"