diff --git a/cortex/cli.py b/cortex/cli.py index b1cfe4a1..eb1f4e78 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -22,6 +22,13 @@ format_package_list, ) from cortex.env_manager import EnvironmentManager, get_env_manager +from cortex.i18n import ( + SUPPORTED_LANGUAGES, + LanguageConfig, + get_language, + set_language, + t, +) from cortex.installation_history import InstallationHistory, InstallationStatus, InstallationType from cortex.llm.interpreter import CommandInterpreter from cortex.network_config import NetworkConfig @@ -160,9 +167,9 @@ def _get_api_key(self) -> str | None: return key # Still no key - self._print_error("No API key found or provided") - cx_print("Run [bold]cortex wizard[/bold] to configure your API key.", "info") - cx_print("Or use [bold]CORTEX_PROVIDER=ollama[/bold] for offline mode.", "info") + self._print_error(t("api_key.not_found")) + cx_print(t("api_key.configure_prompt"), "info") + cx_print(t("api_key.ollama_hint"), "info") return None def _get_provider(self) -> str: @@ -486,21 +493,21 @@ def stack(self, args: argparse.Namespace) -> int: def _handle_stack_list(self, manager: StackManager) -> int: """List all available stacks.""" stacks = manager.list_stacks() - cx_print("\n📦 Available Stacks:\n", "info") + cx_print(f"\n📦 {t('stack.available')}:\n", "info") for stack in stacks: pkg_count = len(stack.get("packages", [])) console.print(f" [green]{stack.get('id', 'unknown')}[/green]") console.print(f" {stack.get('name', 'Unnamed Stack')}") console.print(f" {stack.get('description', 'No description')}") console.print(f" [dim]({pkg_count} packages)[/dim]\n") - cx_print("Use: cortex stack to install a stack", "info") + cx_print(t("stack.use_command"), "info") return 0 def _handle_stack_describe(self, manager: StackManager, stack_id: str) -> int: """Describe a specific stack.""" stack = manager.find_stack(stack_id) if not stack: - self._print_error(f"Stack '{stack_id}' not found. Use --list to see available stacks.") + self._print_error(t("stack.not_found", name=stack_id)) return 1 description = manager.describe_stack(stack_id) console.print(description) @@ -513,20 +520,18 @@ def _handle_stack_install(self, manager: StackManager, args: argparse.Namespace) if suggested_name != original_name: cx_print( - f"💡 No GPU detected, using '{suggested_name}' instead of '{original_name}'", + f"💡 {t('stack.gpu_fallback', original=original_name, suggested=suggested_name)}", "info", ) stack = manager.find_stack(suggested_name) if not stack: - self._print_error( - f"Stack '{suggested_name}' not found. Use --list to see available stacks." - ) + self._print_error(t("stack.not_found", name=suggested_name)) return 1 packages = stack.get("packages", []) if not packages: - self._print_error(f"Stack '{suggested_name}' has no packages configured.") + self._print_error(t("stack.no_packages", name=suggested_name)) return 1 if args.dry_run: @@ -536,28 +541,28 @@ def _handle_stack_install(self, manager: StackManager, args: argparse.Namespace) def _handle_stack_dry_run(self, stack: dict[str, Any], packages: list[str]) -> int: """Preview packages that would be installed without executing.""" - cx_print(f"\n📋 Stack: {stack['name']}", "info") - console.print("\nPackages that would be installed:") + cx_print(f"\n📋 {t('stack.installing', name=stack['name'])}", "info") + console.print(f"\n{t('stack.dry_run_preview')}:") for pkg in packages: console.print(f" • {pkg}") - console.print(f"\nTotal: {len(packages)} packages") - cx_print("\nDry run only - no commands executed", "warning") + console.print(f"\n{t('stack.packages_total', count=len(packages))}") + cx_print(f"\n{t('stack.dry_run_note')}", "warning") return 0 def _handle_stack_real_install(self, stack: dict[str, Any], packages: list[str]) -> int: """Install all packages in the stack.""" - cx_print(f"\n🚀 Installing stack: {stack['name']}\n", "success") + cx_print(f"\n🚀 {t('stack.installing', name=stack['name'])}\n", "success") # Batch into a single LLM request packages_str = " ".join(packages) result = self.install(software=packages_str, execute=True, dry_run=False) if result != 0: - self._print_error(f"Failed to install stack '{stack['name']}'") + self._print_error(t("stack.failed", name=stack["name"])) return 1 - self._print_success(f"\n✅ Stack '{stack['name']}' installed successfully!") - console.print(f"Installed {len(packages)} packages") + self._print_success(f"\n✅ {t('stack.installed', name=stack['name'])}") + console.print(t("stack.packages_installed", count=len(packages))) return 0 # --- Sandbox Commands (Docker-based package testing) --- @@ -574,9 +579,9 @@ def sandbox(self, args: argparse.Namespace) -> int: action = getattr(args, "sandbox_action", None) if not action: - cx_print("\n🐳 Docker Sandbox - Test packages safely before installing\n", "info") - console.print("Usage: cortex sandbox [options]") - console.print("\nCommands:") + cx_print(f"\n🐳 {t('sandbox.header')}\n", "info") + console.print(t("sandbox.usage")) + console.print(f"\n{t('sandbox.commands_header')}:") console.print(" create Create a sandbox environment") console.print(" install Install package in sandbox") console.print(" test [package] Run tests in sandbox") @@ -584,7 +589,7 @@ def sandbox(self, args: argparse.Namespace) -> int: console.print(" cleanup Remove sandbox environment") console.print(" list List all sandboxes") console.print(" exec Execute command in sandbox") - console.print("\nExample workflow:") + console.print(f"\n{t('sandbox.example_workflow')}:") console.print(" cortex sandbox create test-env") console.print(" cortex sandbox install test-env nginx") console.print(" cortex sandbox test test-env") @@ -851,22 +856,20 @@ def install( start_time = datetime.now() try: - self._print_status("🧠", "Understanding request...") + self._print_status("🧠", t("install.analyzing")) interpreter = CommandInterpreter(api_key=api_key, provider=provider) - self._print_status("📦", "Planning installation...") + self._print_status("📦", t("install.planning")) for _ in range(10): - self._animate_spinner("Analyzing system requirements...") + self._animate_spinner(t("progress.analyzing_requirements")) self._clear_line() commands = interpreter.parse(f"install {software}") if not commands: - self._print_error( - "No commands generated. Please try again with a different request." - ) + self._print_error(t("install.no_commands")) return 1 # Extract packages from commands for tracking @@ -878,13 +881,13 @@ def install( InstallationType.INSTALL, packages, commands, start_time ) - self._print_status("⚙️", f"Installing {software}...") - print("\nGenerated commands:") + self._print_status("⚙️", t("install.executing")) + print(f"\n{t('install.commands_would_run')}:") for i, cmd in enumerate(commands, 1): print(f" {i}. {cmd}") if dry_run: - print("\n(Dry run mode - commands not executed)") + print(f"\n({t('install.dry_run_message')})") if install_id: history.update_installation(install_id, InstallationStatus.SUCCESS) return 0 @@ -900,7 +903,7 @@ def progress_callback(current, total, step): print(f"\n[{current}/{total}] {status_emoji} {step.description}") print(f" Command: {step.command}") - print("\nExecuting commands...") + print(f"\n{t('install.executing')}...") if parallel: import asyncio @@ -940,8 +943,10 @@ def parallel_log_callback(message: str, level: str = "info"): total_duration = max_end - min_start if success: - self._print_success(f"{software} installed successfully!") - print(f"\nCompleted in {total_duration:.2f} seconds (parallel mode)") + self._print_success(t("install.package_installed", package=software)) + print( + f"\n{t('progress.completed_in', seconds=f'{total_duration:.2f}')}" + ) if install_id: history.update_installation(install_id, InstallationStatus.SUCCESS) @@ -962,9 +967,9 @@ def parallel_log_callback(message: str, level: str = "info"): error_msg, ) - self._print_error("Installation failed") + self._print_error(t("install.failed")) if error_msg: - print(f" Error: {error_msg}", file=sys.stderr) + print(f" {t('common.error')}: {error_msg}", file=sys.stderr) if install_id: print(f"\n📝 Installation recorded (ID: {install_id})") print(f" View details: cortex history {install_id}") @@ -1000,8 +1005,8 @@ def parallel_log_callback(message: str, level: str = "info"): result = coordinator.execute() if result.success: - self._print_success(f"{software} installed successfully!") - print(f"\nCompleted in {result.total_duration:.2f} seconds") + self._print_success(t("install.package_installed", package=software)) + print(f"\n{t('progress.completed_in', seconds=f'{result.total_duration:.2f}')}") # Record successful installation if install_id: @@ -1444,23 +1449,171 @@ def cache_stats(self) -> int: stats = cache.stats() hit_rate = f"{stats.hit_rate * 100:.1f}%" if stats.total else "0.0%" - cx_header("Cache Stats") - cx_print(f"Hits: {stats.hits}", "info") - cx_print(f"Misses: {stats.misses}", "info") - cx_print(f"Hit rate: {hit_rate}", "info") - cx_print(f"Saved calls (approx): {stats.hits}", "info") + cx_header(t("cache.stats_header")) + cx_print(f"{t('cache.hits')}: {stats.hits}", "info") + cx_print(f"{t('cache.misses')}: {stats.misses}", "info") + cx_print(f"{t('cache.hit_rate')}: {hit_rate}", "info") + cx_print(f"{t('cache.saved_calls')}: {stats.hits}", "info") return 0 except (ImportError, OSError) as e: - self._print_error(f"Unable to read cache stats: {e}") + self._print_error(t("cache.read_error", error=str(e))) return 1 except Exception as e: - self._print_error(f"Unexpected error reading cache stats: {e}") + self._print_error(t("cache.unexpected_error", error=str(e))) if self.verbose: import traceback traceback.print_exc() return 1 + def config(self, args: argparse.Namespace) -> int: + """Handle configuration commands including language settings.""" + action = getattr(args, "config_action", None) + + if not action: + cx_print(t("config.missing_subcommand"), "error") + return 1 + + if action == "language": + return self._config_language(args) + elif action == "show": + return self._config_show() + else: + self._print_error(t("config.unknown_action", action=action)) + return 1 + + def _config_language(self, args: argparse.Namespace) -> int: + """Handle language configuration.""" + lang_config = LanguageConfig() + + # List available languages + if getattr(args, "list", False): + cx_header(t("language.available")) + for code, info in SUPPORTED_LANGUAGES.items(): + current_marker = " ✓" if code == get_language() else "" + console.print( + f" [green]{code}[/green] - {info['name']} ({info['native']}){current_marker}" + ) + return 0 + + # Show language info + if getattr(args, "info", False): + info = lang_config.get_language_info() + cx_header(t("language.current")) + console.print(f" [bold]{info['name']}[/bold] ({info['native_name']})") + console.print(f" [dim]{t('config.code_label')}: {info['language']}[/dim]") + # Translate the source value using proper key mapping + source_translation_keys = { + "environment": "language.set_from_env", + "config": "language.set_from_config", + "auto-detected": "language.auto_detected", + "default": "language.default", + } + source = info.get("source", "") + source_key = source_translation_keys.get(source) + source_display = t(source_key) if source_key else source + console.print(f" [dim]{t('config.source_label')}: {source_display}[/dim]") + + if info.get("env_override"): + console.print(f" [dim]{t('language.set_from_env')}: {info['env_override']}[/dim]") + if info.get("detected_language"): + console.print( + f" [dim]{t('language.auto_detected')}: {info['detected_language']}[/dim]" + ) + return 0 + + # Set language + code = getattr(args, "code", None) + if not code: + # No code provided, show current language and list + current = get_language() + current_info = SUPPORTED_LANGUAGES.get(current, {}) + cx_print( + f"{t('language.current')}: {current_info.get('name', current)} " + f"({current_info.get('native', '')})", + "info", + ) + console.print() + console.print( + f"[dim]{t('language.supported_codes')}: {', '.join(SUPPORTED_LANGUAGES.keys())}[/dim]" + ) + console.print(f"[dim]{t('config.use_command_hint')}[/dim]") + console.print(f"[dim]{t('config.list_hint')}[/dim]") + return 0 + + # Handle 'auto' to clear saved preference + if code.lower() == "auto": + lang_config.clear_language() + from cortex.i18n.translator import reset_translator + + reset_translator() + new_lang = get_language() + new_info = SUPPORTED_LANGUAGES.get(new_lang, {}) + cx_print(t("language.changed", language=new_info.get("native", new_lang)), "success") + console.print(f"[dim]({t('language.auto_detected')})[/dim]") + return 0 + + # Validate and set language + code = code.lower() + if code not in SUPPORTED_LANGUAGES: + self._print_error(t("language.invalid_code", code=code)) + console.print( + f"[dim]{t('language.supported_codes')}: {', '.join(SUPPORTED_LANGUAGES.keys())}[/dim]" + ) + return 1 + + try: + lang_config.set_language(code) + # Reset the global translator to pick up the new language + from cortex.i18n.translator import reset_translator + + reset_translator() + set_language(code) + + lang_info = SUPPORTED_LANGUAGES[code] + cx_print(t("language.changed", language=lang_info["native"]), "success") + return 0 + except (ValueError, RuntimeError) as e: + self._print_error(t("language.set_failed", error=str(e))) + return 1 + + def _config_show(self) -> int: + """Show all current configuration.""" + cx_header(t("config.header")) + + # Language + lang_config = LanguageConfig() + lang_info = lang_config.get_language_info() + console.print(f"[bold]{t('config.language_label')}:[/bold]") + console.print( + f" {lang_info['name']} ({lang_info['native_name']}) " + f"[dim][{lang_info['language']}][/dim]" + ) + # Translate the source identifier to user-friendly text + source_translations = { + "environment": t("language.set_from_env"), + "config": t("language.set_from_config"), + "auto-detected": t("language.auto_detected"), + "default": t("language.default"), + } + source_display = source_translations.get(lang_info["source"], lang_info["source"]) + console.print(f" [dim]{t('config.source_label')}: {source_display}[/dim]") + console.print() + + # API Provider + provider = self._get_provider() + console.print(f"[bold]{t('config.llm_provider_label')}:[/bold]") + console.print(f" {provider}") + console.print() + + # Config paths + console.print(f"[bold]{t('config.config_paths_label')}:[/bold]") + console.print(f" {t('config.preferences_path')}: ~/.cortex/preferences.yaml") + console.print(f" {t('config.history_path')}: ~/.cortex/history.db") + console.print() + + return 0 + def history(self, limit: int = 20, status: str | None = None, show_id: str | None = None): """Show installation history""" history = InstallationHistory() @@ -2828,6 +2981,148 @@ def progress_callback(current: int, total: int, step: InstallationStep) -> None: # -------------------------- +def _is_ascii(s: str) -> bool: + """Check if a string contains only ASCII characters.""" + try: + s.encode("ascii") + return True + except UnicodeEncodeError: + return False + + +def _normalize_for_lookup(s: str) -> str: + """ + Normalize a string for lookup, handling Latin and non-Latin scripts differently. + + For ASCII/Latin text: casefold for case-insensitive matching (handles accented chars) + For non-Latin text (e.g., 中文): keep unchanged to preserve meaning + + Uses casefold() instead of lower() because: + - casefold() handles accented Latin characters better (e.g., "Español", "Français") + - casefold() is more aggressive and handles edge cases like German ß -> ss + + This prevents issues like: + - "中文".lower() producing the same string but creating duplicate keys + - Meaningless normalization of non-Latin scripts + """ + if _is_ascii(s): + return s.casefold() + # For non-ASCII Latin scripts (accented chars like é, ñ, ü), use casefold + # Only keep unchanged for truly non-Latin scripts (CJK, Arabic, etc.) + try: + # Check if string contains any Latin characters (a-z, A-Z, or accented) + # If it does, it's likely a Latin-based language name + import unicodedata + + has_latin = any(unicodedata.category(c).startswith("L") and ord(c) < 0x3000 for c in s) + if has_latin: + return s.casefold() + except Exception: + pass + return s + + +def _resolve_language_name(name: str) -> str | None: + """ + Resolve a language name or code to a supported language code. + + Accepts: + - Language codes: en, es, fr, de, zh + - English names: English, Spanish, French, German, Chinese + - Native names: Español, Français, Deutsch, 中文 + + Args: + name: Language name or code (case-insensitive for Latin scripts) + + Returns: + Language code if found, None otherwise + + Note: + Non-Latin scripts (e.g., Chinese 中文) are matched exactly without + case normalization, since .lower() is meaningless for these scripts + and could create key collisions. + """ + name = name.strip() + name_normalized = _normalize_for_lookup(name) + + # Direct code match (codes are always ASCII/lowercase) + if name_normalized in SUPPORTED_LANGUAGES: + return name_normalized + + # Build lookup tables for names + # Using a list of tuples to handle potential key collisions properly + name_to_code: dict[str, str] = {} + + for code, info in SUPPORTED_LANGUAGES.items(): + english_name = info["name"] + native_name = info["native"] + + # English names are always ASCII, use casefold for case-insensitive matching + name_to_code[english_name.casefold()] = code + + # Native names: normalize using _normalize_for_lookup + # - Latin scripts (Español, Français): casefold for case-insensitive matching + # - Non-Latin scripts (中文): store as-is only + native_normalized = _normalize_for_lookup(native_name) + name_to_code[native_normalized] = code + + # Also store original native name for exact match + # (handles case where user types exactly "Español" with correct accent) + if native_name != native_normalized: + name_to_code[native_name] = code + + # Try to find a match using normalized input + if name_normalized in name_to_code: + return name_to_code[name_normalized] + + # Try exact match for non-ASCII input + if name in name_to_code: + return name_to_code[name] + + return None + + +def _handle_set_language(language_input: str) -> int: + """ + Handle the --set-language global flag. + + Args: + language_input: Language name or code from user + + Returns: + Exit code (0 for success, 1 for error) + """ + # Resolve the language name to a code + lang_code = _resolve_language_name(language_input) + + if not lang_code: + # Show error with available options + cx_print(t("language.invalid_code", code=language_input), "error") + console.print() + console.print(f"[bold]{t('language.supported_languages_header')}[/bold]") + for code, info in SUPPORTED_LANGUAGES.items(): + console.print(f" • {info['name']} ({info['native']}) - code: [green]{code}[/green]") + return 1 + + # Set the language + try: + lang_config = LanguageConfig() + lang_config.set_language(lang_code) + + # Reset and update global translator + from cortex.i18n.translator import reset_translator + + reset_translator() + set_language(lang_code) + + lang_info = SUPPORTED_LANGUAGES[lang_code] + cx_print(t("language.changed", language=lang_info["native"]), "success") + return 0 + except Exception as e: + cx_print(t("language.set_failed", error=str(e)), "error") + return 1 + + def show_rich_help(): """Display a beautifully formatted help table using the Rich library. @@ -2943,6 +3238,13 @@ def main(): # Global flags parser.add_argument("--version", "-V", action="version", version=f"cortex {VERSION}") parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output") + parser.add_argument( + "--set-language", + "--language", + dest="set_language", + metavar="LANG", + help="Set display language (e.g., English, Spanish, Español, es, zh)", + ) subparsers = parser.add_subparsers(dest="command", help="Available commands") @@ -3166,6 +3468,27 @@ def main(): cache_subs = cache_parser.add_subparsers(dest="cache_action", help="Cache actions") cache_subs.add_parser("stats", help="Show cache statistics") + # --- Config commands (including language settings) --- + config_parser = subparsers.add_parser("config", help="Configure Cortex settings") + config_subs = config_parser.add_subparsers(dest="config_action", help="Configuration actions") + + # config language - set language + config_lang_parser = config_subs.add_parser("language", help="Set display language") + config_lang_parser.add_argument( + "code", + nargs="?", + help="Language code (en, es, fr, de, zh) or 'auto' for auto-detection", + ) + config_lang_parser.add_argument( + "--list", "-l", action="store_true", help="List available languages" + ) + config_lang_parser.add_argument( + "--info", "-i", action="store_true", help="Show current language configuration" + ) + + # config show - show all configuration + config_subs.add_parser("show", help="Show all current configuration") + # --- Sandbox Commands (Docker-based package testing) --- sandbox_parser = subparsers.add_parser( "sandbox", help="Test packages in isolated Docker sandbox" @@ -3548,6 +3871,18 @@ def main(): args = parser.parse_args() + # Handle --set-language global flag first (before any command) + if getattr(args, "set_language", None): + result = _handle_set_language(args.set_language) + # Only return early if no command is specified + # This allows: cortex --set-language es install nginx + if not args.command: + return result + # If language setting failed, still return the error + if result != 0: + return result + # Otherwise continue with the command execution + # The Guard: Check for empty commands before starting the CLI if not args.command: show_rich_help() @@ -3623,6 +3958,8 @@ def main(): return 1 elif args.command == "env": return cli.env(args) + elif args.command == "config": + return cli.config(args) elif args.command == "upgrade": from cortex.licensing import open_upgrade_page diff --git a/cortex/i18n/__init__.py b/cortex/i18n/__init__.py new file mode 100644 index 00000000..115dcdeb --- /dev/null +++ b/cortex/i18n/__init__.py @@ -0,0 +1,58 @@ +""" +Internationalization (i18n) module for Cortex Linux CLI. + +Provides multi-language support with: +- Message translation with interpolation +- OS language auto-detection +- Language preference persistence +- Locale-aware date/time and number formatting + +Usage: + from cortex.i18n import t, get_translator, set_language, get_language + + # Simple translation + print(t("install.success")) + + # Translation with variables + print(t("install.package_installed", package="docker", version="24.0.5")) + + # Change language + set_language("es") + +Supported languages: + - en: English (default) + - es: Spanish + - fr: French + - de: German + - zh: Chinese (Simplified) +""" + +from cortex.i18n.config import LanguageConfig +from cortex.i18n.detector import detect_os_language +from cortex.i18n.formatter import LocaleFormatter +from cortex.i18n.translator import ( + SUPPORTED_LANGUAGES, + get_language, + get_language_info, + get_supported_languages, + get_translator, + set_language, + t, +) + +__all__ = [ + # Core translation + "t", + "get_translator", + "set_language", + "get_language", + "get_language_info", + "get_supported_languages", + "SUPPORTED_LANGUAGES", + # Configuration + "LanguageConfig", + # Detection + "detect_os_language", + # Formatting + "LocaleFormatter", +] diff --git a/cortex/i18n/config.py b/cortex/i18n/config.py new file mode 100644 index 00000000..dd69b488 --- /dev/null +++ b/cortex/i18n/config.py @@ -0,0 +1,374 @@ +""" +Language configuration persistence for Cortex Linux CLI. + +Handles: +- Reading/writing language preference to ~/.cortex/preferences.yaml +- Language validation +- Integration with existing Cortex configuration system +- Thread-safe and process-safe file access + +Concurrency Safety: +- Thread locks (threading.Lock) protect against race conditions within a single process +- File locks (fcntl.flock) protect against race conditions between multiple processes +- Both are needed because thread locks don't work across process boundaries +""" + +from __future__ import annotations + +import logging +import os +import sys +import threading +from pathlib import Path +from typing import Any + +import yaml + +from cortex.i18n.detector import detect_os_language + +# Get logger for this module +logger = logging.getLogger(__name__) + +DEFAULT_LANGUAGE = "en" + + +def get_supported_language_codes() -> set[str]: + """ + Get the set of supported language codes from the single source of truth. + + This dynamically derives language codes from SUPPORTED_LANGUAGES in translator.py, + ensuring there's only one place where supported languages are defined. + + Returns: + Set of supported language codes (e.g., {"en", "es", "fr", "de", "zh"}) + """ + from cortex.i18n.translator import SUPPORTED_LANGUAGES + + return set(SUPPORTED_LANGUAGES.keys()) + + +class LanguageConfig: + """ + Manages language preference persistence. + + Language preference is stored in ~/.cortex/preferences.yaml + alongside other Cortex preferences. + + Preference resolution order: + 1. CORTEX_LANGUAGE environment variable + 2. User preference in ~/.cortex/preferences.yaml + 3. OS-detected language + 4. Default (English) + + Thread Safety: + Uses threading.Lock for intra-process synchronization and fcntl.flock + for inter-process synchronization. This ensures safe concurrent access + from multiple threads and multiple processes. + """ + + def __init__(self) -> None: + """Initialize the language configuration manager.""" + self.cortex_dir = Path.home() / ".cortex" + self.preferences_file = self.cortex_dir / "preferences.yaml" + self._thread_lock = threading.Lock() + + # Ensure directory exists + self.cortex_dir.mkdir(mode=0o700, exist_ok=True) + + def _acquire_file_lock(self, file_obj: Any, exclusive: bool = False) -> None: + """ + Acquire a file lock for concurrent access. + + Uses fcntl.flock on Unix systems for inter-process synchronization. + On Windows, falls back to no file locking (thread lock still applies). + + Args: + file_obj: Open file object to lock + exclusive: If True, acquire exclusive lock for writing; + if False, acquire shared lock for reading + + Note: + Thread locks alone are insufficient because they only protect + against concurrent access within the same process. When multiple + Cortex processes run simultaneously (e.g., multiple terminal windows), + file locks prevent data corruption. + """ + if sys.platform != "win32": + import fcntl + + lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH + try: + fcntl.flock(file_obj.fileno(), lock_type) + except OSError as e: + logger.debug(f"Could not acquire file lock: {e}") + + def _release_file_lock(self, file_obj: Any) -> None: + """ + Release a file lock. + + Args: + file_obj: Open file object to unlock + """ + if sys.platform != "win32": + import fcntl + + try: + fcntl.flock(file_obj.fileno(), fcntl.LOCK_UN) + except OSError as e: + logger.debug(f"Could not release file lock: {e}") + + def _load_preferences(self) -> dict[str, Any]: + """ + Load preferences from file with proper locking. + + Returns: + Dictionary of preferences, or empty dict on failure + + Handles: + - Missing file (returns empty dict) + - Malformed YAML (returns empty dict, logs warning) + - Empty file (returns empty dict) + - Invalid types (returns empty dict if not a dict) + - Race conditions (uses both thread and file locks) + + Note: + The exists() check and file read are both inside the critical section + to prevent TOCTOU (time-of-check to time-of-use) race conditions. + """ + try: + with self._thread_lock: + if not self.preferences_file.exists(): + return {} + + with open(self.preferences_file, encoding="utf-8") as f: + self._acquire_file_lock(f, exclusive=False) # Shared lock for reading + try: + content = f.read() + if not content.strip(): + # Empty file + return {} + + data = yaml.safe_load(content) + + # Validate that we got a dict + if data is None: + return {} + if not isinstance(data, dict): + logger.warning( + f"Preferences file contains invalid type: {type(data).__name__}, " + "expected dict. Using defaults." + ) + return {} + + return data + finally: + self._release_file_lock(f) + + except yaml.YAMLError as e: + # Log the YAML parsing error but don't crash + logger.warning(f"Malformed YAML in preferences file: {e}. Using defaults.") + return {} + except OSError as e: + # Handle file system errors (file deleted between check and read, permissions, etc.) + logger.debug(f"Could not read preferences file: {e}") + return {} + + def _save_preferences(self, preferences: dict[str, Any]) -> None: + """ + Save preferences to file with proper locking. + + Args: + preferences: Dictionary of preferences to save + + Raises: + RuntimeError: If preferences cannot be saved + + Note: + Uses exclusive file lock to prevent concurrent writes from + corrupting the preferences file. + """ + from cortex.i18n import t + + try: + with self._thread_lock: + # Write atomically by writing to temp file first, then renaming + temp_file = self.preferences_file.with_suffix(".yaml.tmp") + + with open(temp_file, "w", encoding="utf-8") as f: + self._acquire_file_lock(f, exclusive=True) # Exclusive lock for writing + try: + yaml.safe_dump(preferences, f, default_flow_style=False, allow_unicode=True) + finally: + self._release_file_lock(f) + + # Atomic rename + temp_file.rename(self.preferences_file) + + except OSError as e: + error_msg = t("language.set_failed", error=str(e)) + raise RuntimeError(error_msg) from e + + def get_language(self) -> str: + """ + Get the current language preference. + + Resolution order: + 1. CORTEX_LANGUAGE environment variable + 2. User preference in config file + 3. OS-detected language + 4. Default (English) + + Returns: + Language code + """ + supported_codes = get_supported_language_codes() + + # 1. Environment variable override + env_lang = os.environ.get("CORTEX_LANGUAGE", "").lower() + if env_lang in supported_codes: + return env_lang + + # 2. User preference from config file + preferences = self._load_preferences() + saved_lang = preferences.get("language", "") + if isinstance(saved_lang, str): + saved_lang = saved_lang.lower() + if saved_lang in supported_codes: + return saved_lang + + # 3. OS-detected language + detected_lang = detect_os_language() + if detected_lang in supported_codes: + return detected_lang + + # 4. Default + return DEFAULT_LANGUAGE + + def set_language(self, language: str) -> None: + """ + Set the language preference. + + Args: + language: Language code to set + + Raises: + ValueError: If language code is not supported + """ + from cortex.i18n import t + + supported_codes = get_supported_language_codes() + language = language.lower() + + if language not in supported_codes: + raise ValueError( + t("language.invalid_code", code=language) + + " " + + t("language.supported_codes") + + ": " + + ", ".join(sorted(supported_codes)) + ) + + old_language = self.get_language() + preferences = self._load_preferences() + preferences["language"] = language + self._save_preferences(preferences) + + # Audit log the language change + self._log_language_change("set", old_language, language) + + def clear_language(self) -> None: + """ + Clear the saved language preference (use auto-detection instead). + """ + old_language = self.get_language() + preferences = self._load_preferences() + if "language" in preferences: + del preferences["language"] + self._save_preferences(preferences) + # Audit log the language clear + self._log_language_change("clear", old_language, None) + + def get_language_info(self) -> dict[str, Any]: + """ + Get detailed language configuration info. + + Returns: + Dictionary with language info including source + """ + from cortex.i18n.translator import SUPPORTED_LANGUAGES as LANG_INFO + + supported_codes = get_supported_language_codes() + + # Check each source + env_lang = os.environ.get("CORTEX_LANGUAGE", "").lower() + preferences = self._load_preferences() + saved_lang = preferences.get("language", "") + if isinstance(saved_lang, str): + saved_lang = saved_lang.lower() + else: + saved_lang = "" + detected_lang = detect_os_language() + + # Determine effective language and its source + # Note: source values are internal keys, translated at display time via t() + if env_lang in supported_codes: + effective_lang = env_lang + source = "environment" # Translated via t("language.set_from_env") + elif saved_lang in supported_codes: + effective_lang = saved_lang + source = "config" # Translated via t("language.set_from_config") + elif detected_lang in supported_codes: + effective_lang = detected_lang + source = "auto-detected" # Translated via t("language.auto_detected") + else: + effective_lang = DEFAULT_LANGUAGE + source = "default" # Translated via t("language.default") + + return { + "language": effective_lang, + "source": source, + "name": LANG_INFO.get(effective_lang, {}).get("name", ""), + "native_name": LANG_INFO.get(effective_lang, {}).get("native", ""), + "env_override": env_lang if env_lang else None, + "saved_preference": saved_lang if saved_lang else None, + "detected_language": detected_lang, + } + + def _log_language_change( + self, action: str, old_language: str | None, new_language: str | None + ) -> None: + """ + Log language preference changes to the audit history database. + + Args: + action: The action performed ("set" or "clear") + old_language: Previous language code + new_language: New language code (None for clear) + """ + try: + import datetime + + from cortex.installation_history import ( + InstallationHistory, + InstallationType, + ) + + history = InstallationHistory() + + # Build description for the config change + if action == "set": + description = f"language:{old_language}->{new_language}" + else: + description = f"language:{old_language}->auto" + + # Record as CONFIG type operation + history.record_installation( + operation_type=InstallationType.CONFIG, + packages=[description], + commands=[f"cortex config language {new_language or 'auto'}"], + start_time=datetime.datetime.now(), + ) + logger.debug(f"Audit logged language change: {description}") + except Exception as e: + # Don't fail the language change if audit logging fails + logger.warning(f"Failed to audit log language change: {e}") diff --git a/cortex/i18n/detector.py b/cortex/i18n/detector.py new file mode 100644 index 00000000..8d029329 --- /dev/null +++ b/cortex/i18n/detector.py @@ -0,0 +1,191 @@ +""" +OS language auto-detection for Cortex Linux CLI. + +Detects the system language from environment variables: +- LANGUAGE +- LC_ALL +- LC_MESSAGES +- LANG +""" + +from __future__ import annotations + +import os +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + + +def _get_supported_language_codes() -> set[str]: + """ + Get supported language codes from the single source of truth. + + This dynamically derives language codes from SUPPORTED_LANGUAGES in translator.py, + eliminating duplication and ensuring consistency across the codebase. + + Returns: + Set of supported language codes (e.g., {"en", "es", "fr", "de", "zh"}) + """ + # Import here to avoid circular import + from cortex.i18n.translator import SUPPORTED_LANGUAGES + + return set(SUPPORTED_LANGUAGES.keys()) + + +# Extended language code mappings (handle variants) +LANGUAGE_MAPPINGS = { + # English variants + "en": "en", + "en_us": "en", + "en_gb": "en", + "en_au": "en", + "en_ca": "en", + # Spanish variants + "es": "es", + "es_es": "es", + "es_mx": "es", + "es_ar": "es", + "es_co": "es", + "es_cl": "es", + # French variants + "fr": "fr", + "fr_fr": "fr", + "fr_ca": "fr", + "fr_be": "fr", + "fr_ch": "fr", + # German variants + "de": "de", + "de_de": "de", + "de_at": "de", + "de_ch": "de", + # Chinese variants + "zh": "zh", + "zh_cn": "zh", + "zh_tw": "zh", + "zh_hk": "zh", + "chinese": "zh", + # Handle common variations + "c": "en", # C locale defaults to English + "posix": "en", # POSIX locale defaults to English +} + + +def _parse_locale(locale_string: str) -> str | None: + """ + Parse a locale string and extract the language code. + + Handles formats like: + - en_US.UTF-8 + - es_ES + - fr.UTF-8 + - de + - zh_CN.utf8 + - en_US.UTF-8@latin (with modifier) + - sr_RS@latin (Serbian Latin) + + Args: + locale_string: Raw locale string from environment + + Returns: + Normalized language code or None if cannot parse + """ + if not locale_string: + return None + + # Normalize to lowercase and strip whitespace + locale_lower = locale_string.lower().strip() + + # Handle empty or C/POSIX locale + if not locale_lower or locale_lower in ("c", "posix"): + return "en" + + # Normalize hyphens to underscores BEFORE any other processing + # This handles locales like "en-US", "zh-CN", "pt-BR" + locale_lower = locale_lower.replace("-", "_") + + # Remove encoding suffix (e.g., .UTF-8, .utf8) + # Pattern matches: .encoding or .encoding@modifier + locale_lower = re.sub(r"\.[a-z0-9_-]+(@[a-z]+)?$", "", locale_lower) + + # Remove @modifier suffix (e.g., @latin, @cyrillic) + # This handles cases like "sr_rs@latin" after encoding is already removed + locale_lower = re.sub(r"@[a-z]+$", "", locale_lower) + + # Try direct mapping first (e.g., "en_us", "zh_cn") + if locale_lower in LANGUAGE_MAPPINGS: + return LANGUAGE_MAPPINGS[locale_lower] + + # Try just the language part (before underscore) + if "_" in locale_lower: + lang_part = locale_lower.split("_")[0] + if lang_part in LANGUAGE_MAPPINGS: + return LANGUAGE_MAPPINGS[lang_part] + + # Try full locale for regional variants (already normalized) + if locale_lower in LANGUAGE_MAPPINGS: + return LANGUAGE_MAPPINGS[locale_lower] + + return None + + +def detect_os_language() -> str: + """ + Detect the OS language from environment variables. + + Checks environment variables in order: + 1. LANGUAGE (GNU gettext) + 2. LC_ALL (overrides all LC_* variables) + 3. LC_MESSAGES (controls message language) + 4. LANG (general locale setting) + + The first valid, supported language found is returned. + + Returns: + Detected language code, or 'en' as fallback + + Examples: + With LANG=es_ES.UTF-8: returns 'es' + With LC_ALL=fr_FR: returns 'fr' + With LANGUAGE=de: returns 'de' + """ + # Environment variables to check, in priority order + env_vars = ["LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"] + + for var in env_vars: + value = os.environ.get(var, "") + if not value: + continue + + # LANGUAGE can have multiple values separated by ':' + if var == "LANGUAGE": + for lang_part in value.split(":"): + parsed = _parse_locale(lang_part) + if parsed and parsed in _get_supported_language_codes(): + return parsed + else: + parsed = _parse_locale(value) + if parsed and parsed in _get_supported_language_codes(): + return parsed + + # Default fallback + return "en" + + +def get_os_locale_info() -> dict[str, str | None]: + """ + Get detailed OS locale information for debugging. + + Returns: + Dictionary with all relevant locale environment variables + """ + env_vars = ["LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG", "LC_CTYPE", "LC_TIME", "LC_NUMERIC"] + + info = {} + for var in env_vars: + info[var] = os.environ.get(var) + + info["detected_language"] = detect_os_language() + + return info diff --git a/cortex/i18n/formatter.py b/cortex/i18n/formatter.py new file mode 100644 index 00000000..a6250396 --- /dev/null +++ b/cortex/i18n/formatter.py @@ -0,0 +1,432 @@ +""" +Locale-aware formatting for Cortex Linux CLI. + +Provides locale-specific formatting for: +- Date and time +- Numbers and currencies +- File sizes +- Durations +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +# ============================================================================= +# Time Constants +# ============================================================================= +# These named constants replace magic numbers for improved readability +# and maintainability. All values are in seconds. + +SECONDS_PER_MINUTE = 60 +SECONDS_PER_HOUR = 3600 # 60 * 60 +SECONDS_PER_DAY = 86400 # 60 * 60 * 24 +SECONDS_PER_WEEK = 604800 # 60 * 60 * 24 * 7 +SECONDS_PER_MONTH = 2592000 # 60 * 60 * 24 * 30 (approximate) +SECONDS_PER_YEAR = 31536000 # 60 * 60 * 24 * 365 (approximate) + +# Language-specific formatting configurations +LOCALE_CONFIGS = { + "en": { + "date_format": "%Y-%m-%d", + "time_format": "%I:%M %p", + "datetime_format": "%Y-%m-%d %I:%M %p", + "datetime_full": "%B %d, %Y at %I:%M %p", + "decimal_separator": ".", + "thousands_separator": ",", + "time_ago": { + "seconds": "{n} seconds ago", + "second": "1 second ago", + "minutes": "{n} minutes ago", + "minute": "1 minute ago", + "hours": "{n} hours ago", + "hour": "1 hour ago", + "days": "{n} days ago", + "day": "1 day ago", + "weeks": "{n} weeks ago", + "week": "1 week ago", + "months": "{n} months ago", + "month": "1 month ago", + "years": "{n} years ago", + "year": "1 year ago", + "just_now": "just now", + }, + "file_size_units": ["B", "KB", "MB", "GB", "TB"], + }, + "es": { + "date_format": "%d/%m/%Y", + "time_format": "%H:%M", + "datetime_format": "%d/%m/%Y %H:%M", + "datetime_full": "%d de %B de %Y a las %H:%M", + "decimal_separator": ",", + "thousands_separator": ".", + "time_ago": { + "seconds": "hace {n} segundos", + "second": "hace 1 segundo", + "minutes": "hace {n} minutos", + "minute": "hace 1 minuto", + "hours": "hace {n} horas", + "hour": "hace 1 hora", + "days": "hace {n} días", + "day": "hace 1 día", + "weeks": "hace {n} semanas", + "week": "hace 1 semana", + "months": "hace {n} meses", + "month": "hace 1 mes", + "years": "hace {n} años", + "year": "hace 1 año", + "just_now": "ahora mismo", + }, + "file_size_units": ["B", "KB", "MB", "GB", "TB"], + }, + "fr": { + "date_format": "%d/%m/%Y", + "time_format": "%H:%M", + "datetime_format": "%d/%m/%Y %H:%M", + "datetime_full": "%d %B %Y à %H:%M", + "decimal_separator": ",", + "thousands_separator": " ", + "time_ago": { + "seconds": "il y a {n} secondes", + "second": "il y a 1 seconde", + "minutes": "il y a {n} minutes", + "minute": "il y a 1 minute", + "hours": "il y a {n} heures", + "hour": "il y a 1 heure", + "days": "il y a {n} jours", + "day": "il y a 1 jour", + "weeks": "il y a {n} semaines", + "week": "il y a 1 semaine", + "months": "il y a {n} mois", + "month": "il y a 1 mois", + "years": "il y a {n} ans", + "year": "il y a 1 an", + "just_now": "à l'instant", + }, + "file_size_units": ["o", "Ko", "Mo", "Go", "To"], + }, + "de": { + "date_format": "%d.%m.%Y", + "time_format": "%H:%M", + "datetime_format": "%d.%m.%Y %H:%M", + "datetime_full": "%d. %B %Y um %H:%M", + "decimal_separator": ",", + "thousands_separator": ".", + "time_ago": { + "seconds": "vor {n} Sekunden", + "second": "vor 1 Sekunde", + "minutes": "vor {n} Minuten", + "minute": "vor 1 Minute", + "hours": "vor {n} Stunden", + "hour": "vor 1 Stunde", + "days": "vor {n} Tagen", + "day": "vor 1 Tag", + "weeks": "vor {n} Wochen", + "week": "vor 1 Woche", + "months": "vor {n} Monaten", + "month": "vor 1 Monat", + "years": "vor {n} Jahren", + "year": "vor 1 Jahr", + "just_now": "gerade eben", + }, + "file_size_units": ["B", "KB", "MB", "GB", "TB"], + }, + "zh": { + "date_format": "%Y年%m月%d日", + "time_format": "%H:%M", + "datetime_format": "%Y年%m月%d日 %H:%M", + "datetime_full": "%Y年%m月%d日 %H:%M", + "decimal_separator": ".", + "thousands_separator": ",", + "time_ago": { + "seconds": "{n}秒前", + "second": "1秒前", + "minutes": "{n}分钟前", + "minute": "1分钟前", + "hours": "{n}小时前", + "hour": "1小时前", + "days": "{n}天前", + "day": "1天前", + "weeks": "{n}周前", + "week": "1周前", + "months": "{n}个月前", + "month": "1个月前", + "years": "{n}年前", + "year": "1年前", + "just_now": "刚刚", + }, + "file_size_units": ["B", "KB", "MB", "GB", "TB"], + }, +} + + +class LocaleFormatter: + """ + Provides locale-aware formatting for various data types. + + Automatically uses the current language setting from the i18n module. + """ + + def __init__(self, language: str = "en"): + """ + Initialize the formatter with a language. + + Args: + language: Language code (defaults to English) + """ + self._language = language if language in LOCALE_CONFIGS else "en" + + @property + def language(self) -> str: + """Get the current language.""" + return self._language + + @language.setter + def language(self, value: str) -> None: + """Set the language.""" + if value in LOCALE_CONFIGS: + self._language = value + else: + self._language = "en" + + def _get_config(self) -> dict[str, Any]: + """Get the locale configuration for the current language.""" + return LOCALE_CONFIGS.get(self._language, LOCALE_CONFIGS["en"]) + + def format_date(self, dt: datetime) -> str: + """ + Format a date according to locale conventions. + + Args: + dt: datetime object to format + + Returns: + Formatted date string + """ + config = self._get_config() + return dt.strftime(config["date_format"]) + + def format_time(self, dt: datetime) -> str: + """ + Format a time according to locale conventions. + + Args: + dt: datetime object to format + + Returns: + Formatted time string + """ + config = self._get_config() + return dt.strftime(config["time_format"]) + + def format_datetime(self, dt: datetime, full: bool = False) -> str: + """ + Format a datetime according to locale conventions. + + Args: + dt: datetime object to format + full: Use full format (e.g., "January 15, 2024 at 3:30 PM") + + Returns: + Formatted datetime string + """ + config = self._get_config() + format_key = "datetime_full" if full else "datetime_format" + return dt.strftime(config[format_key]) + + def format_number(self, number: int | float, decimals: int = 0) -> str: + """ + Format a number according to locale conventions. + + Args: + number: Number to format + decimals: Number of decimal places + + Returns: + Formatted number string + """ + config = self._get_config() + decimal_sep = config["decimal_separator"] + thousands_sep = config["thousands_separator"] + + if decimals > 0: + formatted = f"{number:,.{decimals}f}" + else: + formatted = f"{int(number):,}" + + # Replace separators according to locale + if decimal_sep != "." or thousands_sep != ",": + # Use placeholder to avoid replacement conflicts + formatted = formatted.replace(",", "\x00") + formatted = formatted.replace(".", decimal_sep) + formatted = formatted.replace("\x00", thousands_sep) + + return formatted + + def format_file_size(self, size_bytes: int) -> str: + """ + Format a file size in human-readable form. + + Args: + size_bytes: Size in bytes + + Returns: + Formatted file size (e.g., "1.5 GB") + """ + config = self._get_config() + units = config["file_size_units"] + + if size_bytes == 0: + return f"0 {units[0]}" + + size = float(size_bytes) + unit_index = 0 + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + # Format with appropriate decimals + if size >= 100: + formatted_size = self.format_number(size, 0) + elif size >= 10: + formatted_size = self.format_number(size, 1) + else: + formatted_size = self.format_number(size, 2) + + return f"{formatted_size} {units[unit_index]}" + + def format_time_ago(self, dt: datetime, now: datetime | None = None) -> str: + """ + Format a datetime as a relative time string. + + Args: + dt: datetime to format + now: Current time (defaults to now) + + Returns: + Relative time string (e.g., "5 minutes ago") + """ + if now is None: + now = datetime.now() + + config = self._get_config() + time_ago = config["time_ago"] + + diff = now - dt + seconds = int(diff.total_seconds()) + + if seconds < 0: + return time_ago["just_now"] + + if seconds < 5: + return time_ago["just_now"] + elif seconds < SECONDS_PER_MINUTE: + return time_ago["second"] if seconds == 1 else time_ago["seconds"].format(n=seconds) + elif seconds < SECONDS_PER_HOUR: + minutes = seconds // SECONDS_PER_MINUTE + return time_ago["minute"] if minutes == 1 else time_ago["minutes"].format(n=minutes) + elif seconds < SECONDS_PER_DAY: + hours = seconds // SECONDS_PER_HOUR + return time_ago["hour"] if hours == 1 else time_ago["hours"].format(n=hours) + elif seconds < SECONDS_PER_WEEK: + days = seconds // SECONDS_PER_DAY + return time_ago["day"] if days == 1 else time_ago["days"].format(n=days) + elif seconds < SECONDS_PER_MONTH: + weeks = seconds // SECONDS_PER_WEEK + return time_ago["week"] if weeks == 1 else time_ago["weeks"].format(n=weeks) + elif seconds < SECONDS_PER_YEAR: + months = seconds // SECONDS_PER_MONTH + return time_ago["month"] if months == 1 else time_ago["months"].format(n=months) + else: + years = seconds // SECONDS_PER_YEAR + return time_ago["year"] if years == 1 else time_ago["years"].format(n=years) + + def format_duration(self, seconds: float) -> str: + """ + Format a duration in seconds as a human-readable string. + + Args: + seconds: Duration in seconds + + Returns: + Formatted duration (e.g., "2m 30s") + """ + if seconds < 1: + return f"{seconds * 1000:.0f}ms" + elif seconds < SECONDS_PER_MINUTE: + return f"{seconds:.1f}s" + elif seconds < SECONDS_PER_HOUR: + minutes = int(seconds // SECONDS_PER_MINUTE) + secs = int(seconds % SECONDS_PER_MINUTE) + return f"{minutes}m {secs}s" if secs > 0 else f"{minutes}m" + else: + hours = int(seconds // SECONDS_PER_HOUR) + minutes = int((seconds % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE) + return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" + + +# Global formatter instance +_formatter: LocaleFormatter | None = None + + +def get_formatter() -> LocaleFormatter: + """ + Get or create the global formatter instance. + + The formatter is kept in sync with the current application language. + If the language has changed since the formatter was created, it will + be updated automatically. + + Returns: + The global LocaleFormatter instance + """ + global _formatter + from cortex.i18n.translator import get_language + + current_language = get_language() + + if _formatter is None: + # Create new formatter with current language + _formatter = LocaleFormatter(language=current_language) + elif _formatter.language != current_language: + # Language has changed - update the formatter + _formatter.language = current_language + + return _formatter + + +def format_date(dt: datetime) -> str: + """Format a date using the global formatter.""" + return get_formatter().format_date(dt) + + +def format_time(dt: datetime) -> str: + """Format a time using the global formatter.""" + return get_formatter().format_time(dt) + + +def format_datetime(dt: datetime, full: bool = False) -> str: + """Format a datetime using the global formatter.""" + return get_formatter().format_datetime(dt, full) + + +def format_number(number: int | float, decimals: int = 0) -> str: + """Format a number using the global formatter.""" + return get_formatter().format_number(number, decimals) + + +def format_file_size(size_bytes: int) -> str: + """Format a file size using the global formatter.""" + return get_formatter().format_file_size(size_bytes) + + +def format_time_ago(dt: datetime) -> str: + """Format a relative time using the global formatter.""" + return get_formatter().format_time_ago(dt) + + +def format_duration(seconds: float) -> str: + """Format a duration using the global formatter.""" + return get_formatter().format_duration(seconds) diff --git a/cortex/i18n/locales/de.yaml b/cortex/i18n/locales/de.yaml new file mode 100644 index 00000000..b7385c63 --- /dev/null +++ b/cortex/i18n/locales/de.yaml @@ -0,0 +1,357 @@ +# German (de) - Deutsch +# Cortex Linux CLI Nachrichtenkatalog +# +# Deutsche Übersetzung der CLI-Nachrichten. +# Quelldatei: en.yaml + +# ============================================================================= +# Allgemeine Nachrichten +# ============================================================================= +common: + success: "Erfolg" + error: "Fehler" + warning: "Warnung" + info: "Information" + yes: "ja" + no: "nein" + cancel: "Abbrechen" + confirm: "Bestätigen" + done: "Fertig" + failed: "Fehlgeschlagen" + loading: "Laden..." + processing: "Verarbeitung..." + please_wait: "Bitte warten..." + operation_cancelled: "Vorgang abgebrochen" + unknown_error: "Ein unbekannter Fehler ist aufgetreten" + action_completed: "{action} erfolgreich abgeschlossen" + action_failed: "{action} fehlgeschlagen: {error}" + +# ============================================================================= +# Sprachkonfiguration +# ============================================================================= +language: + changed: "Sprache geändert zu {language}" + current: "Aktuelle Sprache" + auto_detected: "Automatisch vom System erkannt" + set_from_env: "Aus CORTEX_LANGUAGE Umgebungsvariable gesetzt" + set_from_config: "Aus Konfigurationsdatei gesetzt" + default: "Standard" + available: "Verfügbare Sprachen" + invalid_code: "Ungültiger Sprachcode: {code}" + supported_codes: "Unterstützte Sprachcodes" + select_prompt: "Wählen Sie eine Sprache" + switched_to: "Gewechselt zu {language}" + set_failed: "Fehler beim Festlegen der Sprache: {error}" + supported_languages_header: "Unterstützte Sprachen:" + +# ============================================================================= +# CLI-Befehle +# ============================================================================= +cli: + description: "KI-gestützter Linux-Befehlsinterpreter" + help_text: "KI-gestützter Paketmanager für Linux" + tagline: "Sagen Sie Cortex einfach, was Sie installieren möchten." + learn_more: "Mehr erfahren" + + commands: + ask: "Eine Frage zu Ihrem System stellen" + demo: "Cortex in Aktion sehen" + wizard: "API-Schlüssel interaktiv konfigurieren" + status: "Umfassenden Systemstatus anzeigen" + install: "Software installieren" + import: "Abhängigkeiten aus Paketdateien importieren" + history: "Installationsverlauf anzeigen" + rollback: "Eine Installation rückgängig machen" + notify: "Desktop-Benachrichtigungen verwalten" + env: "Umgebungsvariablen verwalten" + cache: "LLM-Cache-Statistiken anzeigen" + stack: "Einen vordefinierten Stack installieren" + docker: "Docker- und Container-Dienstprogramme" + sandbox: "Pakete in Docker-Sandbox testen" + doctor: "System-Gesundheitsprüfung" + config: "Cortex-Einstellungen konfigurieren" + + args: + software: "Zu installierende Software" + question: "Frage in natürlicher Sprache" + execute: "Befehle ausführen" + dry_run: "Nur Befehle anzeigen (nicht ausführen)" + parallel: "Parallele Ausführung aktivieren" + verbose: "Detaillierte Ausgabe anzeigen" + yes: "Bestätigungen überspringen" + +# ============================================================================= +# Config-Befehl +# ============================================================================= +config: + missing_subcommand: "Bitte geben Sie einen Unterbefehl an (language/show)" + unknown_action: "Unbekannte Konfigurationsaktion: {action}" + +# ============================================================================= +# Installation +# ============================================================================= +install: + analyzing: "System wird analysiert..." + planning: "Installation wird geplant..." + executing: "Installation wird ausgeführt..." + verifying: "Installation wird überprüft..." + + success: "Installation abgeschlossen!" + failed: "Installation fehlgeschlagen" + package_installed: "{package} erfolgreich installiert" + package_installed_version: "{package} ({version}) erfolgreich installiert" + packages_installed: "{count} Pakete installiert" + + dry_run_header: "Simulationsergebnisse" + dry_run_message: "Simulation abgeschlossen. Verwenden Sie --execute, um Änderungen anzuwenden." + commands_would_run: "Befehle, die ausgeführt würden" + + step_progress: "Schritt {current}/{total}" + step_executing: "Ausführung von Schritt {step}: {description}" + step_completed: "Schritt abgeschlossen" + step_failed: "Schritt fehlgeschlagen" + + no_commands: "Keine Befehle generiert" + invalid_request: "Ungültige Installationsanfrage" + api_error: "API-Fehler: {error}" + package_not_found: "Paket nicht gefunden: {package}" + + confirm_install: "Mit der Installation fortfahren?" + confirm_install_count: "{count} Pakete installieren?" + +# ============================================================================= +# Stack-Verwaltung +# ============================================================================= +stack: + available: "Verfügbare Stacks" + not_found: "Stack '{name}' nicht gefunden. Verwenden Sie --list, um verfügbare Stacks zu sehen." + no_packages: "Stack '{name}' hat keine konfigurierten Pakete." + installing: "Stack wird installiert: {name}" + installed: "Stack '{name}' erfolgreich installiert!" + failed: "Installation von Stack '{name}' fehlgeschlagen" + packages_installed: "{count} Pakete installiert" + packages_total: "Gesamt: {count} Pakete" + dry_run_preview: "Pakete, die installiert würden" + dry_run_note: "Nur Simulation - keine Befehle ausgeführt" + use_command: "Verwenden Sie: cortex stack um einen Stack zu installieren" + gpu_fallback: "Keine GPU erkannt, verwende '{suggested}' anstelle von '{original}'" + +# ============================================================================= +# Sandbox +# ============================================================================= +sandbox: + header: "Docker Sandbox - Pakete sicher vor der Installation testen" + usage: "Verwendung: cortex sandbox [optionen]" + commands_header: "Befehle" + example_workflow: "Beispiel-Arbeitsablauf" + + creating: "Sandbox '{name}' wird erstellt..." + created: "Sandbox-Umgebung '{name}' erstellt" + installing: "'{package}' wird in Sandbox '{name}' installiert..." + installed_in_sandbox: "{package} in Sandbox installiert" + testing: "Tests werden in Sandbox '{name}' ausgeführt..." + cleaning: "Sandbox '{name}' wird entfernt..." + cleaned: "Sandbox '{name}' entfernt" + + test_passed: "Test bestanden" + test_failed: "Test fehlgeschlagen" + tests_summary: "{passed}/{total} Tests bestanden" + + docker_required: "Docker ist für Sandbox-Befehle erforderlich" + not_found: "Sandbox nicht gefunden" + already_exists: "Sandbox existiert bereits" + +# ============================================================================= +# Verlauf +# ============================================================================= +history: + title: "Installationsverlauf" + empty: "Kein Installationsverlauf gefunden" + id: "ID" + date: "Datum" + action: "Aktion" + packages: "Pakete" + status: "Status" + + status_success: "Erfolg" + status_failed: "Fehlgeschlagen" + status_rolled_back: "Rückgängig gemacht" + + details_for: "Details für Installation #{id}" + +# ============================================================================= +# Rollback +# ============================================================================= +rollback: + rolling_back: "Installation #{id} wird rückgängig gemacht..." + success: "Rollback erfolgreich abgeschlossen" + failed: "Rollback fehlgeschlagen" + not_found: "Installation #{id} nicht gefunden" + already_rolled_back: "Installation #{id} wurde bereits rückgängig gemacht" + confirm: "Sind Sie sicher, dass Sie diese Installation rückgängig machen möchten?" + +# ============================================================================= +# Doctor / Gesundheitsprüfung +# ============================================================================= +doctor: + running: "System-Gesundheitsprüfungen werden ausgeführt..." + checks_complete: "{count} Prüfungen abgeschlossen" + all_passed: "Alle Prüfungen bestanden!" + issues_found: "{count} Probleme gefunden" + + check_api_key: "API-Schlüssel-Konfiguration" + check_network: "Netzwerkverbindung" + check_disk_space: "Festplattenplatz" + check_permissions: "Dateiberechtigungen" + check_dependencies: "Systemabhängigkeiten" + + passed: "Bestanden" + warning: "Warnung" + failed: "Fehlgeschlagen" + +# ============================================================================= +# Assistent +# ============================================================================= +wizard: + welcome: "Willkommen beim Cortex-Einrichtungsassistenten!" + step: "Schritt {step} von {total}" + + api_key_prompt: "Geben Sie Ihren API-Schlüssel ein" + api_key_saved: "API-Schlüssel erfolgreich gespeichert" + api_key_invalid: "Ungültiges API-Schlüssel-Format" + + select_provider: "Wählen Sie Ihren LLM-Anbieter" + provider_anthropic: "Anthropic (Claude)" + provider_openai: "OpenAI (GPT)" + provider_ollama: "Ollama (Lokal)" + + setup_complete: "Einrichtung abgeschlossen!" + ready_message: "Cortex ist einsatzbereit" + +# ============================================================================= +# Docker +# ============================================================================= +docker: + scanning_permissions: "Suche nach Docker-bezogenen Berechtigungsproblemen..." + operation_cancelled: "Vorgang abgebrochen" + permissions_fixed: "Berechtigungen erfolgreich korrigiert!" + permission_check_failed: "Berechtigungsprüfung fehlgeschlagen: {error}" + +# ============================================================================= +# Rollen +# ============================================================================= +role: + sensing_context: "KI erfasst Systemkontext und Aktivitätsmuster..." + set_success: "Rolle festgelegt auf: {role}" + persist_failed: "Fehler beim Speichern der Rolle: {error}" + fetching_recommendations: "Hole maßgeschneiderte KI-Empfehlungen für {role}..." + +# ============================================================================= +# Benachrichtigungen +# ============================================================================= +notify: + config_header: "Aktuelle Benachrichtigungskonfiguration" + status: "Status" + enabled: "Aktiviert" + disabled: "Deaktiviert" + dnd_window: "Nicht-Stören-Zeitfenster" + history_file: "Verlaufsdatei" + + notifications_enabled: "Benachrichtigungen aktiviert" + notifications_disabled: "Benachrichtigungen deaktiviert (Kritische Warnungen werden weiterhin angezeigt)" + dnd_updated: "Nicht-Stören-Zeitfenster aktualisiert: {start} - {end}" + + missing_subcommand: "Bitte geben Sie einen Unterbefehl an (config/enable/disable/dnd/send)" + invalid_time_format: "Ungültiges Zeitformat. Verwenden Sie HH:MM (z.B. 22:00)" + message_required: "Nachricht erforderlich" + +# ============================================================================= +# Umgebungsverwaltung +# ============================================================================= +env: + audit_header: "Umgebungs-Audit" + config_files_scanned: "Gescannte Konfigurationsdateien" + variables_found: "Gefundene Umgebungsvariablen" + + path_added: "'{path}' zu PATH hinzugefügt" + path_removed: "'{path}' aus PATH entfernt" + path_already_exists: "'{path}' ist bereits in PATH" + path_not_found: "'{path}' nicht in PATH gefunden" + + persist_note: "Für aktuelle Shell verwenden: source {path}" + +# ============================================================================= +# API-Schlüssel-Nachrichten +# ============================================================================= +api_key: + not_found: "Kein API-Schlüssel gefunden" + configure_prompt: "Führen Sie 'cortex wizard' aus, um Ihren API-Schlüssel zu konfigurieren." + ollama_hint: "Oder verwenden Sie CORTEX_PROVIDER=ollama für Offline-Modus." + using_provider: "Verwende {provider} API-Schlüssel" + using_ollama: "Verwende Ollama (kein API-Schlüssel erforderlich)" + +# ============================================================================= +# Cache +# ============================================================================= +cache: + stats_header: "LLM-Cache-Statistiken" + hits: "Cache-Treffer" + misses: "Cache-Fehler" + hit_rate: "Trefferquote: {rate}%" + entries: "Gesamteinträge" + size: "Cache-Größe" + cleared: "Cache geleert" + +# ============================================================================= +# UI-Beschriftungen +# ============================================================================= +ui: + operation_cancelled: "Vorgang abgebrochen" + error_prefix: "Fehler" + promotion_cancelled: "Beförderung abgebrochen" + install_required_sdk: "Installieren Sie das erforderliche SDK oder verwenden Sie CORTEX_PROVIDER=ollama" + would_run: "Würde ausführen" + note_add_persist: "Hinweis: Fügen Sie --persist hinzu, um dies dauerhaft zu machen" + install_with_pip: "Installieren mit: pip install {package}" + no_variables_found: "Keine Umgebungsvariablen für '{app}' gefunden" + no_variables_to_export: "Keine Umgebungsvariablen zum Exportieren für '{app}'" + no_variables_imported: "Keine Variablen importiert" + no_env_data_found: "Keine Umgebungsdaten für '{app}' gefunden" + no_applications_found: "Keine Anwendungen mit gespeicherten Umgebungen" + no_variables_to_load: "Keine Variablen zum Laden für '{app}'" + fix_path_duplicates: "Führen Sie 'cortex env path dedupe' aus, um PATH-Duplikate zu beheben" + usage_env_import: "Verwendung: cortex env import " + use_template_details: "Verwenden Sie 'cortex env template show ' für Details" + scanning_directory: "Verzeichnis wird gescannt..." + no_dependency_files: "Keine Abhängigkeitsdateien im aktuellen Verzeichnis gefunden" + no_packages_found: "Keine Pakete in der Datei gefunden" + no_packages_in_deps: "Keine Pakete in Abhängigkeitsdateien gefunden" + no_install_commands: "Keine Installationsbefehle generiert" + install_with_execute: "Um alle Pakete zu installieren, führen Sie mit dem --execute Flag aus" + example_import: "Beispiel: cortex import {file_path} --execute" + example_import_all: "Beispiel: cortex import --all --execute" + installation_cancelled: "Installation abgebrochen" + +# ============================================================================= +# Fehlermeldungen +# ============================================================================= +errors: + generic: "Ein Fehler ist aufgetreten" + unexpected: "Unerwarteter Fehler: {error}" + network_error: "Netzwerkfehler - bitte überprüfen Sie Ihre Verbindung" + permission_denied: "Zugriff verweigert" + file_not_found: "Datei nicht gefunden" + invalid_input: "Ungültige Eingabe" + timeout: "Vorgang abgelaufen" + +# ============================================================================= +# Fortschrittsanzeigen +# ============================================================================= +progress: + analyzing_requirements: "Systemanforderungen werden analysiert..." + updating_packages: "Paketlisten werden aktualisiert..." + downloading: "Herunterladen..." + extracting: "Extrahieren..." + configuring: "Konfigurieren..." + cleaning_up: "Aufräumen..." + completed_in: "Abgeschlossen in {seconds} Sekunden" diff --git a/cortex/i18n/locales/en.yaml b/cortex/i18n/locales/en.yaml new file mode 100644 index 00000000..b98008c7 --- /dev/null +++ b/cortex/i18n/locales/en.yaml @@ -0,0 +1,471 @@ +# English (en) - Source language +# Cortex Linux CLI Message Catalog +# +# This is the source language file. All other language files should +# contain translations of these messages. +# +# Key naming convention: +# - Use dot notation for hierarchy (e.g., install.success) +# - Use snake_case for key names +# - Group by feature/module +# +# Variable interpolation: +# - Use {variable_name} syntax +# - Document variables in comments above the message + +# ============================================================================= +# Common messages used across the application +# ============================================================================= +common: + success: "Success" + error: "Error" + warning: "Warning" + info: "Info" + yes: "yes" + no: "no" + cancel: "Cancel" + confirm: "Confirm" + done: "Done" + failed: "Failed" + loading: "Loading..." + processing: "Processing..." + please_wait: "Please wait..." + operation_cancelled: "Operation cancelled" + unknown_error: "An unknown error occurred" + # {action} - the action being performed + action_completed: "{action} completed successfully" + # {action}, {error} - action and error message + action_failed: "{action} failed: {error}" + +# ============================================================================= +# Language configuration +# ============================================================================= +language: + # {language} - the language name in native form + changed: "Language changed to {language}" + current: "Current language" + auto_detected: "Auto-detected from system" + set_from_env: "Set from CORTEX_LANGUAGE environment variable" + set_from_config: "Set from configuration file" + default: "Default" + available: "Available languages" + # {code} - invalid language code + invalid_code: "Invalid language code: {code}" + supported_codes: "Supported language codes" + select_prompt: "Select a language" + # {language} - language name + switched_to: "Switched to {language}" + # {error} - error message + set_failed: "Failed to set language: {error}" + supported_languages_header: "Supported languages:" + +# ============================================================================= +# CLI commands +# ============================================================================= +cli: + # Main help text + description: "AI-powered Linux command interpreter" + help_text: "AI-powered package manager for Linux" + tagline: "Just tell Cortex what you want to install." + learn_more: "Learn more" + + # Command descriptions + commands: + ask: "Ask a question about your system" + demo: "See Cortex in action" + wizard: "Configure API key interactively" + status: "Show comprehensive system status and health checks" + install: "Install software" + import: "Import dependencies from package files" + history: "View installation history" + rollback: "Undo an installation" + notify: "Manage desktop notifications" + env: "Manage environment variables" + cache: "Show LLM cache statistics" + stack: "Install a predefined stack" + docker: "Docker and container utilities" + sandbox: "Test packages in Docker sandbox" + doctor: "System health check" + config: "Configure Cortex settings" + + # Argument help + args: + software: "Software to install" + question: "Natural language question" + execute: "Execute commands" + dry_run: "Show commands only (do not execute)" + parallel: "Enable parallel execution" + verbose: "Show detailed output" + yes: "Skip confirmation prompts" + +# ============================================================================= +# Config command +# ============================================================================= +config: + missing_subcommand: "Please specify a subcommand (language/show)" + unknown_action: "Unknown config action: {action}" + header: "Cortex Configuration" + language_label: "Language" + source_label: "Source" + llm_provider_label: "LLM Provider" + config_paths_label: "Config Paths" + preferences_path: "Preferences" + history_path: "History" + use_command_hint: "Use: cortex config language to change" + list_hint: "Use: cortex config language --list for details" + code_label: "Code" + +# ============================================================================= +# Cache statistics +# ============================================================================= +cache: + stats_header: "LLM Cache Statistics" + hits: "Cache Hits" + misses: "Cache Misses" + # {rate} - hit rate percentage + hit_rate: "Hit Rate: {rate}%" + entries: "Total Entries" + size: "Cache Size" + cleared: "Cache cleared" + saved_calls: "Saved calls (approx)" + read_error: "Unable to read cache stats: {error}" + unexpected_error: "Unexpected error reading cache stats: {error}" + +# ============================================================================= +# UI Labels (common labels used throughout the interface) +# ============================================================================= +ui: + operation_cancelled: "Operation cancelled" + error_prefix: "Error" + promotion_cancelled: "Promotion cancelled" + install_required_sdk: "Install required SDK or use CORTEX_PROVIDER=ollama" + would_run: "Would run" + note_add_persist: "Note: Add --persist to make this permanent" + install_with_pip: "Install with: pip install {package}" + no_variables_found: "No environment variables set for '{app}'" + no_variables_to_export: "No environment variables to export for '{app}'" + no_variables_imported: "No variables imported" + no_env_data_found: "No environment data found for '{app}'" + no_applications_found: "No applications with stored environments" + no_variables_to_load: "No variables to load for '{app}'" + fix_path_duplicates: "Run 'cortex env path dedupe' to fix PATH duplicates" + usage_env_import: "Usage: cortex env import " + use_template_details: "Use 'cortex env template show ' for details" + scanning_directory: "Scanning directory..." + no_dependency_files: "No dependency files found in current directory" + no_packages_found: "No packages found in file" + no_packages_in_deps: "No packages found in dependency files" + no_install_commands: "No install commands generated" + install_with_execute: "To install all packages, run with --execute flag" + example_import: "Example: cortex import {file_path} --execute" + example_import_all: "Example: cortex import --all --execute" + installation_cancelled: "Installation cancelled" + +# ============================================================================= +# Installation +# ============================================================================= +install: + # Status messages + analyzing: "Analyzing system..." + planning: "Planning installation..." + executing: "Executing installation..." + verifying: "Verifying installation..." + + # Results + success: "Installation complete!" + failed: "Installation failed" + # {package} - package name + package_installed: "{package} installed successfully" + # {package}, {version} - package details + package_installed_version: "{package} ({version}) installed successfully" + # {count} - number of packages + packages_installed: "{count} packages installed" + + # Dry run + dry_run_header: "Dry-run results" + dry_run_message: "Dry-run completed. Use --execute to apply changes." + commands_would_run: "Commands that would run" + + # Progress + # {current}, {total} - step numbers + step_progress: "Step {current}/{total}" + # {step}, {description} - step info + step_executing: "Executing step {step}: {description}" + step_completed: "Step completed" + step_failed: "Step failed" + + # Errors + no_commands: "No commands generated" + invalid_request: "Invalid installation request" + # {error} - error message + api_error: "API error: {error}" + # {package} - package name + package_not_found: "Package not found: {package}" + + # Confirmation + confirm_install: "Proceed with installation?" + # {count} - package count + confirm_install_count: "Install {count} packages?" + +# ============================================================================= +# Stack management +# ============================================================================= +stack: + available: "Available Stacks" + # {name} - stack name + not_found: "Stack '{name}' not found. Use --list to see available stacks." + no_packages: "Stack '{name}' has no packages configured." + # {name} - stack name + installing: "Installing stack: {name}" + # {name} - stack name + installed: "Stack '{name}' installed successfully!" + # {name} - stack name + failed: "Failed to install stack '{name}'" + # {count} - package count + packages_installed: "Installed {count} packages" + packages_total: "Total: {count} packages" + dry_run_preview: "Packages that would be installed" + dry_run_note: "Dry run only - no commands executed" + use_command: "Use: cortex stack to install a stack" + # {original}, {suggested} - stack names + gpu_fallback: "No GPU detected, using '{suggested}' instead of '{original}'" + +# ============================================================================= +# Sandbox +# ============================================================================= +sandbox: + header: "Docker Sandbox - Test packages safely before installing" + usage: "Usage: cortex sandbox [options]" + commands_header: "Commands" + example_workflow: "Example workflow" + environments_header: "Sandbox Environments" + + # Actions + # {name} - sandbox name + creating: "Creating sandbox '{name}'..." + created: "Sandbox environment '{name}' created" + # {package}, {name} - package and sandbox names + installing: "Installing '{package}' in sandbox '{name}'..." + installed_in_sandbox: "{package} installed in sandbox" + installed_main_system: "{package} installed on main system" + # {name} - sandbox name + testing: "Running tests in sandbox '{name}'..." + # {name} - sandbox name + cleaning: "Removing sandbox '{name}'..." + cleaned: "Sandbox '{name}' removed" + + # Results + test_passed: "Test passed" + test_failed: "Test failed" + all_tests_passed: "All tests passed" + # {passed}, {total} - test counts + tests_summary: "{passed}/{total} tests passed" + + # Errors + docker_required: "Docker is required for sandbox commands" + docker_only_for_sandbox: "Docker is required only for sandbox commands." + not_found: "Sandbox not found" + already_exists: "Sandbox already exists" + no_sandboxes: "No sandbox environments found" + create_hint: "Create one with: cortex sandbox create " + list_hint: "Use 'cortex sandbox list' to see available sandboxes." + + # Promotion + promote_package: "Installing '{package}' on main system..." + promotion_cancelled: "Promotion cancelled" + would_run: "Would run: {command}" + +# ============================================================================= +# History +# ============================================================================= +history: + title: "Installation History" + empty: "No installation history found" + # Column headers + id: "ID" + date: "Date" + action: "Action" + packages: "Packages" + status: "Status" + + # Status values + status_success: "Success" + status_failed: "Failed" + status_rolled_back: "Rolled back" + + # {id} - installation ID + details_for: "Details for installation #{id}" + +# ============================================================================= +# Rollback +# ============================================================================= +rollback: + # {id} - installation ID + rolling_back: "Rolling back installation #{id}..." + success: "Rollback completed successfully" + failed: "Rollback failed" + # {id} - installation ID + not_found: "Installation #{id} not found" + # {id} - installation ID + already_rolled_back: "Installation #{id} was already rolled back" + confirm: "Are you sure you want to roll back this installation?" + +# ============================================================================= +# Doctor / Health check +# ============================================================================= +doctor: + running: "Running system health checks..." + # {count} - number of checks + checks_complete: "{count} checks completed" + all_passed: "All checks passed!" + # {count} - issue count + issues_found: "{count} issues found" + + # Check names + check_api_key: "API Key Configuration" + check_network: "Network Connectivity" + check_disk_space: "Disk Space" + check_permissions: "File Permissions" + check_dependencies: "System Dependencies" + + # Results + passed: "Passed" + warning: "Warning" + failed: "Failed" + +# ============================================================================= +# Wizard +# ============================================================================= +wizard: + welcome: "Welcome to Cortex Setup Wizard!" + # {step}, {total} - step numbers + step: "Step {step} of {total}" + + # API key setup + api_key_prompt: "Enter your API key" + api_key_saved: "API key saved successfully" + api_key_invalid: "Invalid API key format" + export_api_key_hint: "Please export your API key in your shell profile." + + # Provider selection + select_provider: "Select your LLM provider" + provider_anthropic: "Anthropic (Claude)" + provider_openai: "OpenAI (GPT)" + provider_ollama: "Ollama (Local)" + + # Completion + setup_complete: "Setup complete!" + ready_message: "Cortex is ready to use" + +# ============================================================================= +# Docker permissions +# ============================================================================= +docker: + scanning_permissions: "Scanning for Docker-related permission issues..." + operation_cancelled: "Operation cancelled" + permissions_fixed: "Permissions fixed successfully!" + permission_check_failed: "Permission check failed: {error}" + +# ============================================================================= +# Roles +# ============================================================================= +role: + sensing_context: "AI is sensing system context and activity patterns..." + set_success: "Role set to: {role}" + persist_failed: "Failed to persist role: {error}" + fetching_recommendations: "Fetching tailored AI recommendations for {role}..." + +# ============================================================================= +# Environment management (extended) +# ============================================================================= +env: + audit_header: "Environment Audit" + config_files_scanned: "Config Files Scanned" + variables_found: "Environment Variables Found" + template_header: "Template: {name}" + templates_available: "Available Environment Templates" + applications_header: "Applications with Environments" + health_check_header: "Environment Health Check ({shell} shell)" + path_entries_header: "PATH Entries" + path_dedupe_header: "PATH Deduplication" + path_cleanup_header: "PATH Cleanup" + environment_header: "Environment: {app}" + encrypted_stored: "Variable encrypted and stored" + variable_set: "Environment variable set" + install_cryptography: "Install with: pip install cryptography" + write_file_failed: "Failed to write file: {error}" + read_file_failed: "Failed to read file: {error}" + apply_template_failed: "Failed to apply template '{name}'" + persist_failed: "Failed to persist: {error}" + persist_removal_failed: "Failed to persist removal: {error}" + + # PATH operations + path_added: "Added '{path}' to PATH" + path_removed: "Removed '{path}' from PATH" + path_already_exists: "'{path}' is already in PATH" + path_not_found: "'{path}' not found in PATH" + + # {path} - config file path + persist_note: "To use in current shell: source {path}" + +# ============================================================================= +# Notifications +# ============================================================================= +notify: + config_header: "Current Notification Configuration" + status: "Status" + enabled: "Enabled" + disabled: "Disabled" + dnd_window: "Do Not Disturb Window" + history_file: "History File" + + # Actions + notifications_enabled: "Notifications enabled" + notifications_disabled: "Notifications disabled (Critical alerts will still show)" + # {start}, {end} - time values + dnd_updated: "DND window updated: {start} - {end}" + + # Errors + missing_subcommand: "Please specify a subcommand (config/enable/disable/dnd/send)" + invalid_time_format: "Invalid time format. Use HH:MM (e.g., 22:00)" + message_required: "Message required" + +# ============================================================================= +# API Key messages +# ============================================================================= +api_key: + not_found: "No API key found or provided" + configure_prompt: "Run 'cortex wizard' to configure your API key." + ollama_hint: "Or use CORTEX_PROVIDER=ollama for offline mode." + # {provider} - provider name + using_provider: "Using {provider} API key" + using_ollama: "Using Ollama (no API key required)" + +# ============================================================================= +# Error messages +# ============================================================================= +errors: + generic: "An error occurred" + # {error} - error message + unexpected: "Unexpected error: {error}" + network_error: "Network error - please check your connection" + permission_denied: "Permission denied" + file_not_found: "File not found" + invalid_input: "Invalid input" + timeout: "Operation timed out" + failed_to: "Failed to {action}: {error}" + history_retrieve_failed: "Failed to retrieve history: {error}" + sdk_required: "Install required SDK or use CORTEX_PROVIDER=ollama" + +# ============================================================================= +# Progress indicators +# ============================================================================= +progress: + analyzing_requirements: "Analyzing system requirements..." + updating_packages: "Updating package lists..." + downloading: "Downloading..." + extracting: "Extracting..." + configuring: "Configuring..." + cleaning_up: "Cleaning up..." + # {seconds} - duration + completed_in: "Completed in {seconds} seconds" diff --git a/cortex/i18n/locales/es.yaml b/cortex/i18n/locales/es.yaml new file mode 100644 index 00000000..67c1de0a --- /dev/null +++ b/cortex/i18n/locales/es.yaml @@ -0,0 +1,357 @@ +# Spanish (es) - Español +# Catálogo de mensajes de Cortex Linux CLI +# +# Traducción al español de los mensajes del CLI. +# Archivo fuente: en.yaml + +# ============================================================================= +# Mensajes comunes +# ============================================================================= +common: + success: "Éxito" + error: "Error" + warning: "Advertencia" + info: "Información" + yes: "sí" + no: "no" + cancel: "Cancelar" + confirm: "Confirmar" + done: "Hecho" + failed: "Fallido" + loading: "Cargando..." + processing: "Procesando..." + please_wait: "Por favor espere..." + operation_cancelled: "Operación cancelada" + unknown_error: "Ocurrió un error desconocido" + action_completed: "{action} completado exitosamente" + action_failed: "{action} falló: {error}" + +# ============================================================================= +# Configuración de idioma +# ============================================================================= +language: + changed: "Idioma cambiado a {language}" + current: "Idioma actual" + auto_detected: "Detectado automáticamente del sistema" + set_from_env: "Establecido desde la variable de entorno CORTEX_LANGUAGE" + set_from_config: "Establecido desde el archivo de configuración" + default: "Predeterminado" + available: "Idiomas disponibles" + invalid_code: "Código de idioma inválido: {code}" + supported_codes: "Códigos de idioma soportados" + select_prompt: "Seleccione un idioma" + switched_to: "Cambiado a {language}" + set_failed: "Error al establecer el idioma: {error}" + supported_languages_header: "Idiomas soportados:" + +# ============================================================================= +# Comandos CLI +# ============================================================================= +cli: + description: "Intérprete de comandos Linux con IA" + help_text: "Gestor de paquetes con IA para Linux" + tagline: "Simplemente dile a Cortex qué quieres instalar." + learn_more: "Más información" + + commands: + ask: "Hacer una pregunta sobre tu sistema" + demo: "Ver Cortex en acción" + wizard: "Configurar clave API interactivamente" + status: "Mostrar estado completo del sistema" + install: "Instalar software" + import: "Importar dependencias de archivos de paquetes" + history: "Ver historial de instalación" + rollback: "Deshacer una instalación" + notify: "Gestionar notificaciones de escritorio" + env: "Gestionar variables de entorno" + cache: "Mostrar estadísticas de caché LLM" + stack: "Instalar una pila predefinida" + docker: "Utilidades de Docker y contenedores" + sandbox: "Probar paquetes en sandbox de Docker" + doctor: "Verificación de salud del sistema" + config: "Configurar ajustes de Cortex" + + args: + software: "Software a instalar" + question: "Pregunta en lenguaje natural" + execute: "Ejecutar comandos" + dry_run: "Mostrar comandos solamente (no ejecutar)" + parallel: "Habilitar ejecución paralela" + verbose: "Mostrar salida detallada" + yes: "Omitir confirmaciones" + +# ============================================================================= +# Comando config +# ============================================================================= +config: + missing_subcommand: "Por favor especifique un subcomando (language/show)" + unknown_action: "Acción de configuración desconocida: {action}" + +# ============================================================================= +# Instalación +# ============================================================================= +install: + analyzing: "Analizando sistema..." + planning: "Planificando instalación..." + executing: "Ejecutando instalación..." + verifying: "Verificando instalación..." + + success: "¡Instalación completada!" + failed: "Instalación fallida" + package_installed: "{package} instalado correctamente" + package_installed_version: "{package} ({version}) instalado correctamente" + packages_installed: "{count} paquetes instalados" + + dry_run_header: "Resultados de simulación" + dry_run_message: "Simulación completada. Use --execute para aplicar cambios." + commands_would_run: "Comandos que se ejecutarían" + + step_progress: "Paso {current}/{total}" + step_executing: "Ejecutando paso {step}: {description}" + step_completed: "Paso completado" + step_failed: "Paso fallido" + + no_commands: "No se generaron comandos" + invalid_request: "Solicitud de instalación inválida" + api_error: "Error de API: {error}" + package_not_found: "Paquete no encontrado: {package}" + + confirm_install: "¿Proceder con la instalación?" + confirm_install_count: "¿Instalar {count} paquetes?" + +# ============================================================================= +# Gestión de pilas +# ============================================================================= +stack: + available: "Pilas Disponibles" + not_found: "Pila '{name}' no encontrada. Use --list para ver pilas disponibles." + no_packages: "Pila '{name}' no tiene paquetes configurados." + installing: "Instalando pila: {name}" + installed: "¡Pila '{name}' instalada exitosamente!" + failed: "Error al instalar pila '{name}'" + packages_installed: "{count} paquetes instalados" + packages_total: "Total: {count} paquetes" + dry_run_preview: "Paquetes que se instalarían" + dry_run_note: "Solo simulación - no se ejecutaron comandos" + use_command: "Use: cortex stack para instalar una pila" + gpu_fallback: "GPU no detectada, usando '{suggested}' en lugar de '{original}'" + +# ============================================================================= +# Sandbox +# ============================================================================= +sandbox: + header: "Sandbox Docker - Pruebe paquetes de forma segura antes de instalar" + usage: "Uso: cortex sandbox [opciones]" + commands_header: "Comandos" + example_workflow: "Flujo de trabajo de ejemplo" + + creating: "Creando sandbox '{name}'..." + created: "Entorno sandbox '{name}' creado" + installing: "Instalando '{package}' en sandbox '{name}'..." + installed_in_sandbox: "{package} instalado en sandbox" + testing: "Ejecutando pruebas en sandbox '{name}'..." + cleaning: "Eliminando sandbox '{name}'..." + cleaned: "Sandbox '{name}' eliminado" + + test_passed: "Prueba aprobada" + test_failed: "Prueba fallida" + tests_summary: "{passed}/{total} pruebas aprobadas" + + docker_required: "Docker es requerido para comandos de sandbox" + not_found: "Sandbox no encontrado" + already_exists: "Sandbox ya existe" + +# ============================================================================= +# Historial +# ============================================================================= +history: + title: "Historial de Instalación" + empty: "No se encontró historial de instalación" + id: "ID" + date: "Fecha" + action: "Acción" + packages: "Paquetes" + status: "Estado" + + status_success: "Éxito" + status_failed: "Fallido" + status_rolled_back: "Revertido" + + details_for: "Detalles de instalación #{id}" + +# ============================================================================= +# Reversión +# ============================================================================= +rollback: + rolling_back: "Revirtiendo instalación #{id}..." + success: "Reversión completada exitosamente" + failed: "Reversión fallida" + not_found: "Instalación #{id} no encontrada" + already_rolled_back: "La instalación #{id} ya fue revertida" + confirm: "¿Está seguro de que desea revertir esta instalación?" + +# ============================================================================= +# Doctor / Verificación de salud +# ============================================================================= +doctor: + running: "Ejecutando verificaciones de salud del sistema..." + checks_complete: "{count} verificaciones completadas" + all_passed: "¡Todas las verificaciones pasaron!" + issues_found: "{count} problemas encontrados" + + check_api_key: "Configuración de Clave API" + check_network: "Conectividad de Red" + check_disk_space: "Espacio en Disco" + check_permissions: "Permisos de Archivos" + check_dependencies: "Dependencias del Sistema" + + passed: "Aprobado" + warning: "Advertencia" + failed: "Fallido" + +# ============================================================================= +# Asistente +# ============================================================================= +wizard: + welcome: "¡Bienvenido al Asistente de Configuración de Cortex!" + step: "Paso {step} de {total}" + + api_key_prompt: "Ingrese su clave API" + api_key_saved: "Clave API guardada exitosamente" + api_key_invalid: "Formato de clave API inválido" + + select_provider: "Seleccione su proveedor LLM" + provider_anthropic: "Anthropic (Claude)" + provider_openai: "OpenAI (GPT)" + provider_ollama: "Ollama (Local)" + + setup_complete: "¡Configuración completada!" + ready_message: "Cortex está listo para usar" + +# ============================================================================= +# Docker +# ============================================================================= +docker: + scanning_permissions: "Buscando problemas de permisos relacionados con Docker..." + operation_cancelled: "Operación cancelada" + permissions_fixed: "¡Permisos corregidos exitosamente!" + permission_check_failed: "Verificación de permisos fallida: {error}" + +# ============================================================================= +# Roles +# ============================================================================= +role: + sensing_context: "IA está detectando el contexto del sistema y patrones de actividad..." + set_success: "Rol establecido a: {role}" + persist_failed: "Error al persistir rol: {error}" + fetching_recommendations: "Obteniendo recomendaciones de IA personalizadas para {role}..." + +# ============================================================================= +# Notificaciones +# ============================================================================= +notify: + config_header: "Configuración Actual de Notificaciones" + status: "Estado" + enabled: "Habilitado" + disabled: "Deshabilitado" + dnd_window: "Ventana de No Molestar" + history_file: "Archivo de Historial" + + notifications_enabled: "Notificaciones habilitadas" + notifications_disabled: "Notificaciones deshabilitadas (Alertas críticas aún se mostrarán)" + dnd_updated: "Ventana DND actualizada: {start} - {end}" + + missing_subcommand: "Por favor especifique un subcomando (config/enable/disable/dnd/send)" + invalid_time_format: "Formato de hora inválido. Use HH:MM (ej., 22:00)" + message_required: "Mensaje requerido" + +# ============================================================================= +# Gestión de entorno +# ============================================================================= +env: + audit_header: "Auditoría de Entorno" + config_files_scanned: "Archivos de Configuración Escaneados" + variables_found: "Variables de Entorno Encontradas" + + path_added: "'{path}' agregado a PATH" + path_removed: "'{path}' eliminado de PATH" + path_already_exists: "'{path}' ya está en PATH" + path_not_found: "'{path}' no encontrado en PATH" + + persist_note: "Para usar en shell actual: source {path}" + +# ============================================================================= +# Mensajes de clave API +# ============================================================================= +api_key: + not_found: "No se encontró clave API" + configure_prompt: "Ejecute 'cortex wizard' para configurar su clave API." + ollama_hint: "O use CORTEX_PROVIDER=ollama para modo sin conexión." + using_provider: "Usando clave API de {provider}" + using_ollama: "Usando Ollama (no requiere clave API)" + +# ============================================================================= +# Caché +# ============================================================================= +cache: + stats_header: "Estadísticas de Caché LLM" + hits: "Aciertos de Caché" + misses: "Fallos de Caché" + hit_rate: "Tasa de Aciertos: {rate}%" + entries: "Total de Entradas" + size: "Tamaño de Caché" + cleared: "Caché limpiado" + +# ============================================================================= +# Etiquetas de UI +# ============================================================================= +ui: + operation_cancelled: "Operación cancelada" + error_prefix: "Error" + promotion_cancelled: "Promoción cancelada" + install_required_sdk: "Instale el SDK requerido o use CORTEX_PROVIDER=ollama" + would_run: "Se ejecutaría" + note_add_persist: "Nota: Agregue --persist para hacer esto permanente" + install_with_pip: "Instalar con: pip install {package}" + no_variables_found: "No se encontraron variables de entorno para '{app}'" + no_variables_to_export: "No hay variables de entorno para exportar para '{app}'" + no_variables_imported: "No se importaron variables" + no_env_data_found: "No se encontraron datos de entorno para '{app}'" + no_applications_found: "No hay aplicaciones con entornos almacenados" + no_variables_to_load: "No hay variables para cargar para '{app}'" + fix_path_duplicates: "Ejecute 'cortex env path dedupe' para corregir duplicados en PATH" + usage_env_import: "Uso: cortex env import " + use_template_details: "Use 'cortex env template show ' para detalles" + scanning_directory: "Escaneando directorio..." + no_dependency_files: "No se encontraron archivos de dependencias en el directorio actual" + no_packages_found: "No se encontraron paquetes en el archivo" + no_packages_in_deps: "No se encontraron paquetes en archivos de dependencias" + no_install_commands: "No se generaron comandos de instalación" + install_with_execute: "Para instalar todos los paquetes, ejecute con la bandera --execute" + example_import: "Ejemplo: cortex import {file_path} --execute" + example_import_all: "Ejemplo: cortex import --all --execute" + installation_cancelled: "Instalación cancelada" + +# ============================================================================= +# Mensajes de error +# ============================================================================= +errors: + generic: "Ocurrió un error" + unexpected: "Error inesperado: {error}" + network_error: "Error de red - por favor verifique su conexión" + permission_denied: "Permiso denegado" + file_not_found: "Archivo no encontrado" + invalid_input: "Entrada inválida" + timeout: "La operación expiró" + +# ============================================================================= +# Indicadores de progreso +# ============================================================================= +progress: + analyzing_requirements: "Analizando requisitos del sistema..." + updating_packages: "Actualizando lista de paquetes..." + downloading: "Descargando..." + extracting: "Extrayendo..." + configuring: "Configurando..." + cleaning_up: "Limpiando..." + completed_in: "Completado en {seconds} segundos" diff --git a/cortex/i18n/locales/fr.yaml b/cortex/i18n/locales/fr.yaml new file mode 100644 index 00000000..5cb9e6d2 --- /dev/null +++ b/cortex/i18n/locales/fr.yaml @@ -0,0 +1,357 @@ +# French (fr) - Français +# Catalogue de messages Cortex Linux CLI +# +# Traduction française des messages CLI. +# Fichier source: en.yaml + +# ============================================================================= +# Messages communs +# ============================================================================= +common: + success: "Succès" + error: "Erreur" + warning: "Avertissement" + info: "Information" + yes: "oui" + no: "non" + cancel: "Annuler" + confirm: "Confirmer" + done: "Terminé" + failed: "Échoué" + loading: "Chargement..." + processing: "Traitement..." + please_wait: "Veuillez patienter..." + operation_cancelled: "Opération annulée" + unknown_error: "Une erreur inconnue s'est produite" + action_completed: "{action} terminé avec succès" + action_failed: "{action} a échoué: {error}" + +# ============================================================================= +# Configuration de la langue +# ============================================================================= +language: + changed: "Langue changée en {language}" + current: "Langue actuelle" + auto_detected: "Détectée automatiquement du système" + set_from_env: "Définie depuis la variable d'environnement CORTEX_LANGUAGE" + set_from_config: "Définie depuis le fichier de configuration" + default: "Par défaut" + available: "Langues disponibles" + invalid_code: "Code de langue invalide: {code}" + supported_codes: "Codes de langue supportés" + select_prompt: "Sélectionnez une langue" + switched_to: "Changé en {language}" + set_failed: "Échec de la définition de la langue: {error}" + supported_languages_header: "Langues supportées:" + +# ============================================================================= +# Commandes CLI +# ============================================================================= +cli: + description: "Interpréteur de commandes Linux alimenté par l'IA" + help_text: "Gestionnaire de paquets IA pour Linux" + tagline: "Dites simplement à Cortex ce que vous voulez installer." + learn_more: "En savoir plus" + + commands: + ask: "Poser une question sur votre système" + demo: "Voir Cortex en action" + wizard: "Configurer la clé API interactivement" + status: "Afficher l'état complet du système" + install: "Installer un logiciel" + import: "Importer des dépendances depuis des fichiers" + history: "Voir l'historique d'installation" + rollback: "Annuler une installation" + notify: "Gérer les notifications bureau" + env: "Gérer les variables d'environnement" + cache: "Afficher les statistiques du cache LLM" + stack: "Installer une pile prédéfinie" + docker: "Utilitaires Docker et conteneurs" + sandbox: "Tester des paquets dans le sandbox Docker" + doctor: "Vérification de santé du système" + config: "Configurer les paramètres Cortex" + + args: + software: "Logiciel à installer" + question: "Question en langage naturel" + execute: "Exécuter les commandes" + dry_run: "Afficher les commandes uniquement (ne pas exécuter)" + parallel: "Activer l'exécution parallèle" + verbose: "Afficher la sortie détaillée" + yes: "Passer les confirmations" + +# ============================================================================= +# Commande config +# ============================================================================= +config: + missing_subcommand: "Veuillez spécifier une sous-commande (language/show)" + unknown_action: "Action de configuration inconnue: {action}" + +# ============================================================================= +# Installation +# ============================================================================= +install: + analyzing: "Analyse du système..." + planning: "Planification de l'installation..." + executing: "Exécution de l'installation..." + verifying: "Vérification de l'installation..." + + success: "Installation terminée!" + failed: "Échec de l'installation" + package_installed: "{package} installé avec succès" + package_installed_version: "{package} ({version}) installé avec succès" + packages_installed: "{count} paquets installés" + + dry_run_header: "Résultats de la simulation" + dry_run_message: "Simulation terminée. Utilisez --execute pour appliquer les changements." + commands_would_run: "Commandes qui seraient exécutées" + + step_progress: "Étape {current}/{total}" + step_executing: "Exécution de l'étape {step}: {description}" + step_completed: "Étape terminée" + step_failed: "Étape échouée" + + no_commands: "Aucune commande générée" + invalid_request: "Demande d'installation invalide" + api_error: "Erreur API: {error}" + package_not_found: "Paquet non trouvé: {package}" + + confirm_install: "Procéder à l'installation?" + confirm_install_count: "Installer {count} paquets?" + +# ============================================================================= +# Gestion des piles +# ============================================================================= +stack: + available: "Piles Disponibles" + not_found: "Pile '{name}' non trouvée. Utilisez --list pour voir les piles disponibles." + no_packages: "La pile '{name}' n'a pas de paquets configurés." + installing: "Installation de la pile: {name}" + installed: "Pile '{name}' installée avec succès!" + failed: "Échec de l'installation de la pile '{name}'" + packages_installed: "{count} paquets installés" + packages_total: "Total: {count} paquets" + dry_run_preview: "Paquets qui seraient installés" + dry_run_note: "Simulation uniquement - aucune commande exécutée" + use_command: "Utilisez: cortex stack pour installer une pile" + gpu_fallback: "GPU non détecté, utilisation de '{suggested}' au lieu de '{original}'" + +# ============================================================================= +# Sandbox +# ============================================================================= +sandbox: + header: "Sandbox Docker - Testez les paquets en sécurité avant installation" + usage: "Usage: cortex sandbox [options]" + commands_header: "Commandes" + example_workflow: "Exemple de flux de travail" + + creating: "Création du sandbox '{name}'..." + created: "Environnement sandbox '{name}' créé" + installing: "Installation de '{package}' dans le sandbox '{name}'..." + installed_in_sandbox: "{package} installé dans le sandbox" + testing: "Exécution des tests dans le sandbox '{name}'..." + cleaning: "Suppression du sandbox '{name}'..." + cleaned: "Sandbox '{name}' supprimé" + + test_passed: "Test réussi" + test_failed: "Test échoué" + tests_summary: "{passed}/{total} tests réussis" + + docker_required: "Docker est requis pour les commandes sandbox" + not_found: "Sandbox non trouvé" + already_exists: "Le sandbox existe déjà" + +# ============================================================================= +# Historique +# ============================================================================= +history: + title: "Historique d'Installation" + empty: "Aucun historique d'installation trouvé" + id: "ID" + date: "Date" + action: "Action" + packages: "Paquets" + status: "Statut" + + status_success: "Succès" + status_failed: "Échoué" + status_rolled_back: "Annulé" + + details_for: "Détails de l'installation #{id}" + +# ============================================================================= +# Annulation +# ============================================================================= +rollback: + rolling_back: "Annulation de l'installation #{id}..." + success: "Annulation terminée avec succès" + failed: "Échec de l'annulation" + not_found: "Installation #{id} non trouvée" + already_rolled_back: "L'installation #{id} a déjà été annulée" + confirm: "Êtes-vous sûr de vouloir annuler cette installation?" + +# ============================================================================= +# Doctor / Vérification de santé +# ============================================================================= +doctor: + running: "Exécution des vérifications de santé du système..." + checks_complete: "{count} vérifications terminées" + all_passed: "Toutes les vérifications réussies!" + issues_found: "{count} problèmes trouvés" + + check_api_key: "Configuration de la Clé API" + check_network: "Connectivité Réseau" + check_disk_space: "Espace Disque" + check_permissions: "Permissions de Fichiers" + check_dependencies: "Dépendances Système" + + passed: "Réussi" + warning: "Avertissement" + failed: "Échoué" + +# ============================================================================= +# Assistant +# ============================================================================= +wizard: + welcome: "Bienvenue dans l'Assistant de Configuration Cortex!" + step: "Étape {step} sur {total}" + + api_key_prompt: "Entrez votre clé API" + api_key_saved: "Clé API enregistrée avec succès" + api_key_invalid: "Format de clé API invalide" + + select_provider: "Sélectionnez votre fournisseur LLM" + provider_anthropic: "Anthropic (Claude)" + provider_openai: "OpenAI (GPT)" + provider_ollama: "Ollama (Local)" + + setup_complete: "Configuration terminée!" + ready_message: "Cortex est prêt à utiliser" + +# ============================================================================= +# Docker +# ============================================================================= +docker: + scanning_permissions: "Recherche des problèmes de permissions liés à Docker..." + operation_cancelled: "Opération annulée" + permissions_fixed: "Permissions corrigées avec succès!" + permission_check_failed: "Vérification des permissions échouée: {error}" + +# ============================================================================= +# Rôles +# ============================================================================= +role: + sensing_context: "L'IA détecte le contexte système et les modèles d'activité..." + set_success: "Rôle défini à: {role}" + persist_failed: "Échec de la persistance du rôle: {error}" + fetching_recommendations: "Récupération des recommandations IA personnalisées pour {role}..." + +# ============================================================================= +# Notifications +# ============================================================================= +notify: + config_header: "Configuration Actuelle des Notifications" + status: "Statut" + enabled: "Activé" + disabled: "Désactivé" + dnd_window: "Fenêtre Ne Pas Déranger" + history_file: "Fichier d'Historique" + + notifications_enabled: "Notifications activées" + notifications_disabled: "Notifications désactivées (Les alertes critiques seront toujours affichées)" + dnd_updated: "Fenêtre NPD mise à jour: {start} - {end}" + + missing_subcommand: "Veuillez spécifier une sous-commande (config/enable/disable/dnd/send)" + invalid_time_format: "Format d'heure invalide. Utilisez HH:MM (ex., 22:00)" + message_required: "Message requis" + +# ============================================================================= +# Gestion de l'environnement +# ============================================================================= +env: + audit_header: "Audit de l'Environnement" + config_files_scanned: "Fichiers de Configuration Analysés" + variables_found: "Variables d'Environnement Trouvées" + + path_added: "'{path}' ajouté au PATH" + path_removed: "'{path}' supprimé du PATH" + path_already_exists: "'{path}' est déjà dans le PATH" + path_not_found: "'{path}' non trouvé dans le PATH" + + persist_note: "Pour utiliser dans le shell actuel: source {path}" + +# ============================================================================= +# Messages de clé API +# ============================================================================= +api_key: + not_found: "Aucune clé API trouvée" + configure_prompt: "Exécutez 'cortex wizard' pour configurer votre clé API." + ollama_hint: "Ou utilisez CORTEX_PROVIDER=ollama pour le mode hors ligne." + using_provider: "Utilisation de la clé API {provider}" + using_ollama: "Utilisation d'Ollama (pas de clé API requise)" + +# ============================================================================= +# Cache +# ============================================================================= +cache: + stats_header: "Statistiques du Cache LLM" + hits: "Succès du Cache" + misses: "Échecs du Cache" + hit_rate: "Taux de Succès: {rate}%" + entries: "Total des Entrées" + size: "Taille du Cache" + cleared: "Cache vidé" + +# ============================================================================= +# Étiquettes UI +# ============================================================================= +ui: + operation_cancelled: "Opération annulée" + error_prefix: "Erreur" + promotion_cancelled: "Promotion annulée" + install_required_sdk: "Installez le SDK requis ou utilisez CORTEX_PROVIDER=ollama" + would_run: "Exécuterait" + note_add_persist: "Note: Ajoutez --persist pour rendre ceci permanent" + install_with_pip: "Installer avec: pip install {package}" + no_variables_found: "Aucune variable d'environnement définie pour '{app}'" + no_variables_to_export: "Aucune variable d'environnement à exporter pour '{app}'" + no_variables_imported: "Aucune variable importée" + no_env_data_found: "Aucune donnée d'environnement trouvée pour '{app}'" + no_applications_found: "Aucune application avec des environnements stockés" + no_variables_to_load: "Aucune variable à charger pour '{app}'" + fix_path_duplicates: "Exécutez 'cortex env path dedupe' pour corriger les doublons PATH" + usage_env_import: "Usage: cortex env import " + use_template_details: "Utilisez 'cortex env template show ' pour les détails" + scanning_directory: "Analyse du répertoire..." + no_dependency_files: "Aucun fichier de dépendances trouvé dans le répertoire courant" + no_packages_found: "Aucun paquet trouvé dans le fichier" + no_packages_in_deps: "Aucun paquet trouvé dans les fichiers de dépendances" + no_install_commands: "Aucune commande d'installation générée" + install_with_execute: "Pour installer tous les paquets, exécutez avec le drapeau --execute" + example_import: "Exemple: cortex import {file_path} --execute" + example_import_all: "Exemple: cortex import --all --execute" + installation_cancelled: "Installation annulée" + +# ============================================================================= +# Messages d'erreur +# ============================================================================= +errors: + generic: "Une erreur s'est produite" + unexpected: "Erreur inattendue: {error}" + network_error: "Erreur réseau - veuillez vérifier votre connexion" + permission_denied: "Permission refusée" + file_not_found: "Fichier non trouvé" + invalid_input: "Entrée invalide" + timeout: "L'opération a expiré" + +# ============================================================================= +# Indicateurs de progression +# ============================================================================= +progress: + analyzing_requirements: "Analyse des exigences système..." + updating_packages: "Mise à jour de la liste des paquets..." + downloading: "Téléchargement..." + extracting: "Extraction..." + configuring: "Configuration..." + cleaning_up: "Nettoyage..." + completed_in: "Terminé en {seconds} secondes" diff --git a/cortex/i18n/locales/zh.yaml b/cortex/i18n/locales/zh.yaml new file mode 100644 index 00000000..fa837162 --- /dev/null +++ b/cortex/i18n/locales/zh.yaml @@ -0,0 +1,375 @@ +# Chinese Simplified (zh) - 中文 +# Cortex Linux CLI 消息目录 +# +# CLI消息的中文翻译。 +# 源文件: en.yaml + +# ============================================================================= +# 通用消息 +# ============================================================================= +common: + success: "成功" + error: "错误" + warning: "警告" + info: "信息" + yes: "是" + no: "否" + cancel: "取消" + confirm: "确认" + done: "完成" + failed: "失败" + loading: "加载中..." + processing: "处理中..." + please_wait: "请稍候..." + operation_cancelled: "操作已取消" + unknown_error: "发生未知错误" + action_completed: "{action}成功完成" + action_failed: "{action}失败: {error}" + +# ============================================================================= +# 语言配置 +# ============================================================================= +language: + changed: "语言已更改为{language}" + current: "当前语言" + auto_detected: "从系统自动检测" + set_from_env: "从CORTEX_LANGUAGE环境变量设置" + set_from_config: "从配置文件设置" + default: "默认" + available: "可用语言" + invalid_code: "无效的语言代码: {code}" + supported_codes: "支持的语言代码" + select_prompt: "选择语言" + switched_to: "已切换到{language}" + set_failed: "设置语言失败: {error}" + supported_languages_header: "支持的语言:" + +# ============================================================================= +# CLI命令 +# ============================================================================= +cli: + description: "AI驱动的Linux命令解释器" + help_text: "AI驱动的Linux软件包管理器" + tagline: "只需告诉Cortex您想安装什么。" + learn_more: "了解更多" + + commands: + ask: "询问有关系统的问题" + demo: "查看Cortex演示" + wizard: "交互式配置API密钥" + status: "显示完整的系统状态和健康检查" + install: "安装软件" + import: "从软件包文件导入依赖项" + history: "查看安装历史" + rollback: "撤销安装" + notify: "管理桌面通知" + env: "管理环境变量" + cache: "显示LLM缓存统计" + stack: "安装预定义堆栈" + docker: "Docker和容器工具" + sandbox: "在Docker沙箱中测试软件包" + doctor: "系统健康检查" + config: "配置Cortex设置" + + args: + software: "要安装的软件" + question: "自然语言问题" + execute: "执行命令" + dry_run: "仅显示命令(不执行)" + parallel: "启用并行执行" + verbose: "显示详细输出" + yes: "跳过确认提示" + +# ============================================================================= +# Config命令 +# ============================================================================= +config: + missing_subcommand: "请指定子命令 (language/show)" + unknown_action: "未知的配置操作: {action}" + header: "Cortex 配置" + language_label: "语言" + source_label: "来源" + llm_provider_label: "LLM 提供商" + config_paths_label: "配置路径" + preferences_path: "偏好设置" + history_path: "历史记录" + use_command_hint: "使用: cortex config language <代码> 更改语言" + list_hint: "使用: cortex config language --list 查看详情" + code_label: "代码" + +# ============================================================================= +# UI标签(界面中常用的标签) +# ============================================================================= +ui: + operation_cancelled: "操作已取消" + error_prefix: "错误" + promotion_cancelled: "推广已取消" + install_required_sdk: "安装所需的SDK或使用 CORTEX_PROVIDER=ollama" + would_run: "将执行" + note_add_persist: "注意: 添加 --persist 使其永久生效" + install_with_pip: "使用以下命令安装: pip install {package}" + no_variables_found: "未找到 '{app}' 的环境变量" + no_variables_to_export: "没有可导出的 '{app}' 环境变量" + no_variables_imported: "未导入任何变量" + no_env_data_found: "未找到 '{app}' 的环境数据" + no_applications_found: "没有存储环境的应用程序" + no_variables_to_load: "没有可加载的 '{app}' 变量" + fix_path_duplicates: "运行 'cortex env path dedupe' 修复PATH重复项" + usage_env_import: "用法: cortex env import <应用> <文件>" + use_template_details: "使用 'cortex env template show <名称>' 查看详情" + scanning_directory: "正在扫描目录..." + no_dependency_files: "当前目录未找到依赖文件" + no_packages_found: "文件中未找到软件包" + no_packages_in_deps: "依赖文件中未找到软件包" + no_install_commands: "未生成安装命令" + install_with_execute: "要安装所有软件包,请使用 --execute 标志运行" + example_import: "示例: cortex import {file_path} --execute" + example_import_all: "示例: cortex import --all --execute" + installation_cancelled: "安装已取消" + +# ============================================================================= +# 安装 +# ============================================================================= +install: + analyzing: "分析系统中..." + planning: "规划安装中..." + executing: "执行安装中..." + verifying: "验证安装中..." + + success: "安装完成!" + failed: "安装失败" + package_installed: "{package}安装成功" + package_installed_version: "{package} ({version}) 安装成功" + packages_installed: "已安装{count}个软件包" + + dry_run_header: "模拟运行结果" + dry_run_message: "模拟运行完成。使用--execute应用更改。" + commands_would_run: "将要运行的命令" + + step_progress: "步骤 {current}/{total}" + step_executing: "正在执行步骤 {step}: {description}" + step_completed: "步骤完成" + step_failed: "步骤失败" + + no_commands: "未生成命令" + invalid_request: "无效的安装请求" + api_error: "API错误: {error}" + package_not_found: "未找到软件包: {package}" + + confirm_install: "是否继续安装?" + confirm_install_count: "安装{count}个软件包?" + +# ============================================================================= +# 堆栈管理 +# ============================================================================= +stack: + available: "可用堆栈" + not_found: "未找到堆栈'{name}'。使用--list查看可用堆栈。" + no_packages: "堆栈'{name}'没有配置软件包。" + installing: "正在安装堆栈: {name}" + installed: "堆栈'{name}'安装成功!" + failed: "安装堆栈'{name}'失败" + packages_installed: "已安装{count}个软件包" + packages_total: "共{count}个软件包" + dry_run_preview: "将要安装的软件包" + dry_run_note: "仅模拟运行 - 未执行命令" + use_command: "使用: cortex stack <名称> 安装堆栈" + gpu_fallback: "未检测到GPU,使用'{suggested}'代替'{original}'" + +# ============================================================================= +# 沙箱 +# ============================================================================= +sandbox: + header: "Docker沙箱 - 在安装前安全测试软件包" + usage: "用法: cortex sandbox <命令> [选项]" + commands_header: "命令" + example_workflow: "示例工作流程" + + creating: "正在创建沙箱'{name}'..." + created: "沙箱环境'{name}'已创建" + installing: "正在沙箱'{name}'中安装'{package}'..." + installed_in_sandbox: "{package}已在沙箱中安装" + testing: "正在沙箱'{name}'中运行测试..." + cleaning: "正在删除沙箱'{name}'..." + cleaned: "沙箱'{name}'已删除" + + test_passed: "测试通过" + test_failed: "测试失败" + all_tests_passed: "所有测试通过" + tests_summary: "{passed}/{total}个测试通过" + + docker_required: "沙箱命令需要Docker" + docker_only_for_sandbox: "Docker仅用于沙箱命令。" + not_found: "未找到沙箱" + already_exists: "沙箱已存在" + no_sandboxes: "未找到沙箱环境" + create_hint: "使用以下命令创建: cortex sandbox create <名称>" + list_hint: "使用 'cortex sandbox list' 查看可用沙箱。" + + promote_package: "正在主系统上安装 '{package}'..." + +# ============================================================================= +# 历史 +# ============================================================================= +history: + title: "安装历史" + empty: "未找到安装历史" + id: "ID" + date: "日期" + action: "操作" + packages: "软件包" + status: "状态" + + status_success: "成功" + status_failed: "失败" + status_rolled_back: "已回滚" + + details_for: "安装#{id}的详细信息" + +# ============================================================================= +# 回滚 +# ============================================================================= +rollback: + rolling_back: "正在回滚安装#{id}..." + success: "回滚成功完成" + failed: "回滚失败" + not_found: "未找到安装#{id}" + already_rolled_back: "安装#{id}已经被回滚" + confirm: "确定要回滚此安装吗?" + +# ============================================================================= +# Doctor / 健康检查 +# ============================================================================= +doctor: + running: "正在运行系统健康检查..." + checks_complete: "已完成{count}项检查" + all_passed: "所有检查通过!" + issues_found: "发现{count}个问题" + + check_api_key: "API密钥配置" + check_network: "网络连接" + check_disk_space: "磁盘空间" + check_permissions: "文件权限" + check_dependencies: "系统依赖项" + + passed: "通过" + warning: "警告" + failed: "失败" + +# ============================================================================= +# 向导 +# ============================================================================= +wizard: + welcome: "欢迎使用Cortex设置向导!" + step: "步骤 {step}/{total}" + + api_key_prompt: "请输入您的API密钥" + api_key_saved: "API密钥保存成功" + api_key_invalid: "API密钥格式无效" + export_api_key_hint: "请在shell配置文件中导出您的API密钥。" + + select_provider: "选择您的LLM提供商" + provider_anthropic: "Anthropic (Claude)" + provider_openai: "OpenAI (GPT)" + provider_ollama: "Ollama (本地)" + + setup_complete: "设置完成!" + ready_message: "Cortex已准备就绪" + +# ============================================================================= +# Docker +# ============================================================================= +docker: + scanning_permissions: "正在扫描Docker相关权限问题..." + operation_cancelled: "操作已取消" + permissions_fixed: "权限修复成功!" + permission_check_failed: "权限检查失败: {error}" + +# ============================================================================= +# 角色 +# ============================================================================= +role: + sensing_context: "AI正在感知系统上下文和活动模式..." + set_success: "角色已设置为: {role}" + persist_failed: "角色持久化失败: {error}" + fetching_recommendations: "正在获取针对{role}的定制AI建议..." + +# ============================================================================= +# 通知 +# ============================================================================= +notify: + config_header: "当前通知配置" + status: "状态" + enabled: "已启用" + disabled: "已禁用" + dnd_window: "免打扰时段" + history_file: "历史文件" + + notifications_enabled: "通知已启用" + notifications_disabled: "通知已禁用(重要警告仍会显示)" + dnd_updated: "免打扰时段已更新: {start} - {end}" + + missing_subcommand: "请指定子命令 (config/enable/disable/dnd/send)" + invalid_time_format: "时间格式无效。请使用HH:MM格式(如22:00)" + message_required: "需要消息内容" + +# ============================================================================= +# 环境管理 +# ============================================================================= +env: + audit_header: "环境审计" + config_files_scanned: "已扫描的配置文件" + variables_found: "找到的环境变量" + + path_added: "'{path}'已添加到PATH" + path_removed: "'{path}'已从PATH删除" + path_already_exists: "'{path}'已在PATH中" + path_not_found: "PATH中未找到'{path}'" + + persist_note: "要在当前shell中使用: source {path}" + +# ============================================================================= +# API密钥消息 +# ============================================================================= +api_key: + not_found: "未找到API密钥" + configure_prompt: "运行'cortex wizard'配置您的API密钥。" + ollama_hint: "或使用CORTEX_PROVIDER=ollama进行离线模式。" + using_provider: "使用{provider} API密钥" + using_ollama: "使用Ollama(无需API密钥)" + +# ============================================================================= +# 缓存 +# ============================================================================= +cache: + stats_header: "LLM缓存统计" + hits: "缓存命中" + misses: "缓存未命中" + hit_rate: "命中率: {rate}%" + entries: "总条目数" + size: "缓存大小" + cleared: "缓存已清除" + +# ============================================================================= +# 错误消息 +# ============================================================================= +errors: + generic: "发生错误" + unexpected: "意外错误: {error}" + network_error: "网络错误 - 请检查您的连接" + permission_denied: "权限被拒绝" + file_not_found: "文件未找到" + invalid_input: "输入无效" + timeout: "操作超时" + +# ============================================================================= +# 进度指示器 +# ============================================================================= +progress: + analyzing_requirements: "正在分析系统需求..." + updating_packages: "正在更新软件包列表..." + downloading: "下载中..." + extracting: "解压中..." + configuring: "配置中..." + cleaning_up: "清理中..." + completed_in: "在{seconds}秒内完成" diff --git a/cortex/i18n/translator.py b/cortex/i18n/translator.py new file mode 100644 index 00000000..7d245282 --- /dev/null +++ b/cortex/i18n/translator.py @@ -0,0 +1,346 @@ +""" +Core translation module for Cortex Linux CLI. + +Provides message translation with: +- Message catalog loading from YAML files +- Variable interpolation in messages +- Graceful fallback to English for missing translations +- Debug mode for showing translation keys +""" + +import os +from pathlib import Path +from typing import Any + +import yaml + +# Supported languages with their display names +SUPPORTED_LANGUAGES: dict[str, dict[str, str]] = { + "en": {"name": "English", "native": "English"}, + "es": {"name": "Spanish", "native": "Español"}, + "fr": {"name": "French", "native": "Français"}, + "de": {"name": "German", "native": "Deutsch"}, + "zh": {"name": "Chinese", "native": "中文"}, +} + +DEFAULT_LANGUAGE = "en" + + +class Translator: + """ + Handles message translation with catalog management. + + Features: + - Loads message catalogs from YAML files + - Supports variable interpolation using {variable} syntax + - Falls back to English if translation is missing + - Supports debug mode to show translation keys + """ + + def __init__(self, language: str = DEFAULT_LANGUAGE, debug: bool = False): + """ + Initialize the translator. + + Args: + language: Language code (e.g., 'en', 'es', 'fr') + debug: If True, show translation keys instead of translated text + """ + self._language = language if language in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE + self._debug = debug + self._catalogs: dict[str, dict[str, Any]] = {} + self._locales_dir = Path(__file__).parent / "locales" + + # Load English as fallback + self._load_catalog("en") + + # Load requested language if different from English + if self._language != "en": + self._load_catalog(self._language) + + @property + def language(self) -> str: + """Get the current language code.""" + return self._language + + @language.setter + def language(self, value: str) -> None: + """ + Set the current language. + + Args: + value: Language code to switch to + + Raises: + ValueError: If language code is not supported + """ + if value not in SUPPORTED_LANGUAGES: + raise ValueError( + f"Unsupported language: {value}. " + f"Supported: {', '.join(SUPPORTED_LANGUAGES.keys())}" + ) + self._language = value + if value not in self._catalogs: + self._load_catalog(value) + + @property + def debug(self) -> bool: + """Get debug mode status.""" + return self._debug + + @debug.setter + def debug(self, value: bool) -> None: + """Set debug mode.""" + self._debug = value + + def _load_catalog(self, language: str) -> None: + """ + Load a message catalog from YAML file. + + Args: + language: Language code to load + + Note: + Silently fails if catalog file doesn't exist. + Missing catalogs will fall back to English. + """ + catalog_path = self._locales_dir / f"{language}.yaml" + + if catalog_path.exists(): + try: + with open(catalog_path, encoding="utf-8") as f: + self._catalogs[language] = yaml.safe_load(f) or {} + except (yaml.YAMLError, OSError) as e: + # Log error but continue with empty catalog + self._catalogs[language] = {} + else: + self._catalogs[language] = {} + + def _get_nested_value(self, data: dict[str, Any], key: str) -> str | None: + """ + Get a nested value from a dictionary using dot notation. + + Args: + data: Dictionary to search + key: Dot-separated key (e.g., 'install.success') + + Returns: + The value if found, None otherwise + """ + parts = key.split(".") + current = data + + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + + return str(current) if current is not None else None + + def translate(self, key: str, **kwargs: Any) -> str: + """ + Translate a message key with optional variable interpolation. + + Args: + key: Message key using dot notation (e.g., 'install.success') + **kwargs: Variables to interpolate into the message + + Returns: + Translated message with variables replaced, or the key itself + if no translation is found. + + Examples: + >>> translator.translate("install.success") + "Installation complete!" + + >>> translator.translate("install.package_installed", package="docker") + "docker installed successfully" + """ + if self._debug: + return f"[{key}]" + + # Try current language first + message = self._get_nested_value(self._catalogs.get(self._language, {}), key) + + # Fall back to English + if message is None and self._language != "en": + message = self._get_nested_value(self._catalogs.get("en", {}), key) + + # If still not found, return the key + if message is None: + return key + + # Interpolate variables + if kwargs: + try: + message = message.format(**kwargs) + except (KeyError, IndexError, ValueError): + # If interpolation fails, return message without interpolation + # KeyError: missing named argument, IndexError: missing positional, + # ValueError: malformed format string + pass + + return message + + def get_all_keys(self, language: str | None = None) -> set[str]: + """ + Get all translation keys for a language. + + Args: + language: Language code, defaults to current language + + Returns: + Set of all translation keys + """ + lang = language or self._language + catalog = self._catalogs.get(lang, {}) + return self._extract_keys(catalog) + + def _extract_keys(self, data: dict[str, Any], prefix: str = "") -> set[str]: + """ + Recursively extract all keys from a nested dictionary. + + Args: + data: Dictionary to extract keys from + prefix: Current key prefix + + Returns: + Set of dot-notation keys + """ + keys = set() + for key, value in data.items(): + full_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + keys.update(self._extract_keys(value, full_key)) + else: + keys.add(full_key) + return keys + + def get_missing_translations(self, language: str) -> set[str]: + """ + Find keys that exist in English but not in the target language. + + Args: + language: Target language to check + + Returns: + Set of missing translation keys + """ + if language not in self._catalogs: + self._load_catalog(language) + + en_keys = self.get_all_keys("en") + target_keys = self.get_all_keys(language) + + return en_keys - target_keys + + def reload_catalogs(self) -> None: + """Reload all message catalogs from disk.""" + self._catalogs.clear() + self._load_catalog("en") + if self._language != "en": + self._load_catalog(self._language) + + +# Global translator instance +_translator: Translator | None = None + + +def get_translator() -> Translator: + """ + Get or create the global translator instance. + + Returns: + The global Translator instance + """ + global _translator + if _translator is None: + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + language = config.get_language() + debug = os.environ.get("CORTEX_I18N_DEBUG", "").lower() in ("1", "true", "yes") + _translator = Translator(language=language, debug=debug) + return _translator + + +def set_language(language: str) -> None: + """ + Set the global language. + + Args: + language: Language code to switch to + + Raises: + ValueError: If language code is not supported + """ + translator = get_translator() + translator.language = language + + +def get_language() -> str: + """ + Get the current global language. + + Returns: + Current language code + """ + return get_translator().language + + +def t(key: str, **kwargs: Any) -> str: + """ + Translate a message key (shorthand function). + + This is the primary function for translation throughout the codebase. + + Args: + key: Message key using dot notation + **kwargs: Variables to interpolate + + Returns: + Translated message + + Examples: + >>> t("install.success") + "Installation complete!" + + >>> t("install.package_installed", package="docker") + "docker installed successfully" + """ + return get_translator().translate(key, **kwargs) + + +def reset_translator() -> None: + """Reset the global translator (mainly for testing).""" + global _translator + _translator = None + + +def get_language_info() -> dict[str, str]: + """ + Get information about the current language. + + Returns: + Dictionary with language details: + - code: Language code (e.g., 'es') + - name: English name (e.g., 'Spanish') + - native: Native name (e.g., 'Español') + """ + lang = get_language() + info = SUPPORTED_LANGUAGES.get(lang, SUPPORTED_LANGUAGES["en"]) + return { + "code": lang, + "name": info["name"], + "native": info["native"], + } + + +def get_supported_languages() -> dict[str, dict[str, str]]: + """ + Get all supported languages. + + Returns: + Dictionary mapping language codes to their info. + """ + return SUPPORTED_LANGUAGES.copy() diff --git a/docs/i18n-guide.md b/docs/i18n-guide.md new file mode 100644 index 00000000..069391db --- /dev/null +++ b/docs/i18n-guide.md @@ -0,0 +1,480 @@ +# Cortex Internationalization (i18n) Guide + +This guide covers the internationalization system for the Cortex Linux CLI, including how to use translations in code and how to add new languages. + +## Table of Contents + +1. [Overview](#overview) +2. [Using Translations in Code](#using-translations-in-code) +3. [Adding a New Language](#adding-a-new-language) +4. [Message Catalog Structure](#message-catalog-structure) +5. [Translation Guidelines](#translation-guidelines) +6. [Testing Translations](#testing-translations) +7. [CLI Commands](#cli-commands) + +## Overview + +Cortex supports multiple languages through a YAML-based message catalog system. The i18n module provides: + +- **Message translation** with variable interpolation +- **OS language auto-detection** from environment variables +- **Locale-aware formatting** for dates, times, and numbers +- **Graceful fallback** to English for missing translations + +### Supported Languages + +| Code | Language | Native Name | +|------|----------|-------------| +| `en` | English | English | +| `es` | Spanish | Español | +| `fr` | French | Français | +| `de` | German | Deutsch | +| `zh` | Chinese (Simplified) | 中文 | + +## Using Translations in Code + +### Basic Usage + +```python +from cortex.i18n import t + +# Simple translation +print(t("common.success")) # Output: "Success" (in English) + +# With variable interpolation +print(t("install.package_installed", package="docker")) +# Output: "docker installed successfully" +``` + +### Setting/Getting Language + +```python +from cortex.i18n import set_language, get_language + +# Get current language +current = get_language() # Returns "en", "es", etc. + +# Change language +set_language("es") +print(t("common.success")) # Output: "Éxito" +``` + +### Locale-Aware Formatting + +```python +from cortex.i18n.formatter import LocaleFormatter +from datetime import datetime + +formatter = LocaleFormatter(language="de") + +# Date formatting +dt = datetime(2024, 3, 15) +print(formatter.format_date(dt)) # Output: "15.03.2024" + +# Number formatting +print(formatter.format_number(1234567)) # Output: "1.234.567" + +# File size formatting +print(formatter.format_file_size(1536 * 1024)) # Output: "1.5 MB" + +# Relative time +print(formatter.format_time_ago(past_datetime)) # Output: "vor 5 Minuten" +``` + +### Using the Global Formatter + +```python +from cortex.i18n.formatter import format_date, format_number, format_file_size + +# These use the current global language setting +print(format_date(datetime.now())) +print(format_number(1234567)) +print(format_file_size(1024 * 1024)) +``` + +## Adding a New Language + +### Step 1: Create the Message Catalog + +Create a new YAML file in `cortex/i18n/locales/`: + +```bash +cp cortex/i18n/locales/en.yaml cortex/i18n/locales/.yaml +``` + +### Step 2: Translate Messages + +Edit the new file and translate all messages. Keep the same key structure as the English file. + +```yaml +# Example: Japanese (ja.yaml) +common: + success: "成功" + error: "エラー" + warning: "警告" + # ... translate all keys +``` + +### Step 3: Register the Language + +Add the language to `SUPPORTED_LANGUAGES` in `cortex/i18n/translator.py`: + +```python +SUPPORTED_LANGUAGES: dict[str, dict[str, str]] = { + # ... existing languages ... + "ja": {"name": "Japanese", "native": "日本語"}, +} +``` + +### Step 4: Add Locale Configuration + +Add locale formatting rules in `cortex/i18n/formatter.py`: + +```python +LOCALE_CONFIGS = { + # ... existing configs ... + "ja": { + "date_format": "%Y年%m月%d日", + "time_format": "%H:%M", + "datetime_format": "%Y年%m月%d日 %H:%M", + "decimal_separator": ".", + "thousands_separator": ",", + "time_ago": { + "seconds": "{n}秒前", + "second": "1秒前", + # ... complete the time_ago dict + }, + "file_size_units": ["B", "KB", "MB", "GB", "TB"], + }, +} +``` + +### Step 5: Update Detector (Optional) + +If the language has regional variants, add mappings in `cortex/i18n/detector.py`: + +```python +LANGUAGE_MAPPINGS = { + # ... existing mappings ... + "ja": "ja", + "ja_jp": "ja", +} +``` + +### Step 6: Add Tests + +Add tests for the new language in `tests/test_i18n.py`: + +```python +def test_japanese_translations(self): + """Test Japanese translations.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="ja") + + self.assertEqual(translator.translate("common.success"), "成功") +``` + +### Step 7: Update Documentation + +Update this guide and the README to include the new language. + +## Message Catalog Structure + +### File Location + +Message catalogs are stored in `cortex/i18n/locales/` as YAML files: + +```text +cortex/i18n/locales/ +├── en.yaml # English (source language) +├── es.yaml # Spanish +├── fr.yaml # French +├── de.yaml # German +└── zh.yaml # Chinese +``` + +### Key Naming Convention + +- Use **dot notation** for hierarchy: `category.subcategory.key` +- Use **snake_case** for key names +- Group by feature/module + +```yaml +# Good +install: + success: "Installation complete!" + package_installed: "{package} installed successfully" + +# Bad - flat keys +install_success: "Installation complete!" +installPackageInstalled: "{package} installed successfully" +``` + +### Variable Interpolation + +Use `{variable_name}` syntax for dynamic values: + +```yaml +# In the YAML file +install: + # {package} - the package name + package_installed: "{package} installed successfully" + # {current}, {total} - progress numbers + step_progress: "Step {current}/{total}" +``` + +```python +# In code +t("install.package_installed", package="docker") +# Output: "docker installed successfully" + +t("install.step_progress", current=2, total=5) +# Output: "Step 2/5" +``` + +### Comments + +Use comments to document variables and provide context for translators: + +```yaml +# Variable documentation +install: + # {package} - the name of the package being installed + # {version} - the version number (optional) + package_installed_version: "{package} ({version}) installed successfully" +``` + +## Translation Guidelines + +### For Translators + +1. **Preserve Variables**: Keep all `{variable}` placeholders exactly as they appear in English. + + ```yaml + # English + package_installed: "{package} installed successfully" + + # Spanish - correct + package_installed: "{package} instalado correctamente" + + # Spanish - WRONG (variable modified) + package_installed: "{paquete} instalado correctamente" + ``` + +2. **Match Tone**: Cortex uses a friendly, professional tone. Maintain this in translations. + +3. **Be Concise**: CLI messages should be brief. Avoid overly verbose translations. + +4. **Use Native Conventions**: Use the appropriate date/time formats, quotation marks, and punctuation for your language. + +5. **Test Your Translations**: Run the CLI with your language to verify translations appear correctly. + +### For Developers + +1. **Always Use Translation Keys**: Never hardcode user-facing strings. + + ```python + # Good + cx_print(t("install.success"), "success") + + # Bad + cx_print("Installation complete!", "success") + ``` + +2. **Use Descriptive Keys**: Keys should indicate their purpose. + + ```python + # Good + t("install.confirm_install_count", count=5) + + # Bad + t("install.msg1", count=5) + ``` + +3. **Document Variables**: Add comments in the YAML for translator reference. + +4. **Test with Multiple Languages**: Verify your changes work in all supported languages. + +## Testing Translations + +### Running Translation Tests + +```bash +# Run all i18n tests +pytest tests/test_i18n.py -v + +# Run specific test class +pytest tests/test_i18n.py::TestTranslator -v + +# Run with coverage +pytest tests/test_i18n.py -v --cov=cortex.i18n --cov-report=term-missing +``` + +### Checking for Missing Translations + +```python +from cortex.i18n.translator import Translator + +translator = Translator() + +# Get missing keys for a language +missing = translator.get_missing_translations("es") +print(f"Missing Spanish translations: {missing}") +``` + +### Debug Mode + +Enable debug mode to see translation keys in the CLI: + +```bash +export CORTEX_I18N_DEBUG=1 +cortex install docker +# Shows: [install.analyzing] instead of translated text +``` + +## CLI Commands + +### Quick Language Setting (Human-Readable) + +The easiest way to set your language is using the global `--set-language` flag with the language name: + +```bash +# Using English names +cortex --set-language English +cortex --set-language Spanish +cortex --set-language French +cortex --set-language German +cortex --set-language Chinese + +# Using native names +cortex --set-language Español +cortex --set-language Français +cortex --set-language Deutsch +cortex --set-language 中文 + +# Using language codes +cortex --set-language es +cortex --set-language zh + +# Shorter alias +cortex --language Spanish +``` + +### View Current Language + +```bash +cortex config language +# Output: Current language: English (English) +``` + +### Set Language (Using Code) + +```bash +# Set to Spanish +cortex config language es +# Output: ✓ Idioma cambiado a Español + +# Set to German +cortex config language de +# Output: ✓ Sprache geändert zu Deutsch +``` + +### Use Auto-Detection + +```bash +cortex config language auto +# Uses OS language setting +``` + +### List Available Languages + +```bash +cortex config language --list +# Output: +# ━━━ Available languages ━━━ +# en - English (English) ✓ +# es - Spanish (Español) +# fr - French (Français) +# de - German (Deutsch) +# zh - Chinese (中文) +``` + +### Show Language Info + +```bash +cortex config language --info +# Output: +# ━━━ Current language ━━━ +# English (English) +# Code: en +# Source: config +``` + +### Environment Variable Override + +```bash +# Override language for a single command +CORTEX_LANGUAGE=fr cortex install docker +# Uses French for this command only +``` + +## Language Resolution Order + +The language is determined in this order: + +1. `CORTEX_LANGUAGE` environment variable +2. User preference in `~/.cortex/preferences.yaml` +3. OS-detected language (from `LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG`) +4. Default: English (`en`) + +## File Locations + +| Item | Location | +|------|----------| +| Message catalogs | `cortex/i18n/locales/*.yaml` | +| User preferences | `~/.cortex/preferences.yaml` | +| i18n module | `cortex/i18n/` | +| Tests | `tests/test_i18n.py` | + +## Troubleshooting + +### Translation Not Showing + +1. Verify the language is set correctly: + ```bash + cortex config language --info + ``` + +2. Check if the key exists in the catalog: + ```python + from cortex.i18n.translator import Translator + t = Translator(language="es") + print(t.get_all_keys()) + ``` + +3. Enable debug mode to see keys: + ```bash + CORTEX_I18N_DEBUG=1 cortex install docker + ``` + +### Auto-Detection Not Working + +Check your OS locale settings: +```bash +echo $LANG $LC_ALL $LANGUAGE +``` + +Verify the detected language: +```python +from cortex.i18n.detector import get_os_locale_info +print(get_os_locale_info()) +``` + +### Missing Variable in Translation + +If you see `{variable}` in output, the translation key exists but interpolation failed. Check: + +1. Variable name matches exactly (case-sensitive) +2. The `{}` syntax is correct in the YAML file +3. The variable is passed in code: `t("key", variable=value)` diff --git a/pyproject.toml b/pyproject.toml index 2879e774..7b47d9fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=64.0", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -98,11 +98,12 @@ Discussions = "https://github.com/cortexlinux/cortex/discussions" Changelog = "https://github.com/cortexlinux/cortex/blob/main/CHANGELOG.md" [tool.setuptools] -packages = ["cortex", "cortex.sandbox", "cortex.utils", "cortex.llm", "cortex.kernel_features", "cortex.kernel_features.ebpf"] +packages = ["cortex", "cortex.sandbox", "cortex.utils", "cortex.llm", "cortex.kernel_features", "cortex.kernel_features.ebpf", "cortex.i18n"] include-package-data = true [tool.setuptools.package-data] cortex = ["py.typed"] +"cortex.i18n" = ["locales/*.yaml"] [tool.black] line-length = 100 diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py index ebf36bb8..243bbf31 100644 --- a/tests/integration/test_end_to_end.py +++ b/tests/integration/test_end_to_end.py @@ -62,7 +62,11 @@ def test_cli_dry_run_with_fake_provider(self): result = self._run("python -m cortex.cli install docker --dry-run", env=env) self.assertTrue(result.succeeded(), msg=result.stderr) - self.assertIn("Generated commands", result.stdout) + # i18n: "Commands that would run:" or legacy "Generated commands" + self.assertTrue( + "Commands that would run" in result.stdout or "Generated commands" in result.stdout, + msg=f"Expected dry-run output header not found in: {result.stdout}", + ) self.assertIn("echo Step 1", result.stdout) def test_cli_execute_with_fake_provider(self): @@ -84,7 +88,11 @@ def test_cli_execute_with_fake_provider(self): self.assertTrue(result.succeeded(), msg=result.stderr) # Output formatting may vary (Rich UI vs legacy), but the success text should be present. - self.assertIn("docker installed successfully!", result.stdout) + # i18n: "docker installed successfully" (no exclamation in i18n version) + self.assertTrue( + "docker installed successfully" in result.stdout, + msg=f"Expected success message not found in: {result.stdout}", + ) def test_coordinator_executes_in_container(self): """InstallationCoordinator should execute simple commands inside Docker.""" diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 00000000..9a582540 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,1119 @@ +""" +Tests for the i18n (internationalization) module. + +Tests cover: +- Core translation functionality +- Language switching +- Auto-detection from OS environment +- Locale-aware formatting +- Missing translation handling +- Configuration persistence +""" + +import os +import tempfile +import unittest +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import patch + + +class TestTranslator(unittest.TestCase): + """Tests for the Translator class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset global translator before each test + from cortex.i18n.translator import reset_translator + + reset_translator() + + def tearDown(self): + """Clean up after tests.""" + from cortex.i18n.translator import reset_translator + + reset_translator() + + def test_default_language_is_english(self): + """Test that default language is English.""" + from cortex.i18n.translator import DEFAULT_LANGUAGE, Translator + + translator = Translator() + self.assertEqual(translator.language, DEFAULT_LANGUAGE) + self.assertEqual(translator.language, "en") + + def test_set_language(self): + """Test setting language to supported languages.""" + from cortex.i18n.translator import Translator + + translator = Translator() + + for lang in ["en", "es", "fr", "de", "zh"]: + translator.language = lang + self.assertEqual(translator.language, lang) + + def test_set_unsupported_language_raises(self): + """Test that setting unsupported language raises ValueError.""" + from cortex.i18n.translator import Translator + + translator = Translator() + + with self.assertRaises(ValueError) as context: + translator.language = "xx" + + self.assertIn("Unsupported language", str(context.exception)) + + def test_translate_basic_key(self): + """Test basic translation of a key.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="en") + + # Test a key that should exist in all languages + result = translator.translate("common.success") + self.assertEqual(result, "Success") + + def test_translate_with_variables(self): + """Test translation with variable interpolation.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="en") + + # Test interpolation + result = translator.translate("language.changed", language="English") + self.assertEqual(result, "Language changed to English") + + def test_translate_missing_key_returns_key(self): + """Test that missing keys return the key itself.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="en") + + result = translator.translate("nonexistent.key.path") + self.assertEqual(result, "nonexistent.key.path") + + def test_translate_fallback_to_english(self): + """Test fallback to English for missing translations.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="es") + + # Test a key that exists in English + result = translator.translate("common.success") + # Should return Spanish translation if available + self.assertIsNotNone(result) + self.assertNotEqual(result, "common.success") + + def test_debug_mode(self): + """Test debug mode shows translation keys.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="en", debug=True) + + result = translator.translate("common.success") + self.assertEqual(result, "[common.success]") + + def test_debug_mode_toggle(self): + """Test toggling debug mode.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="en") + self.assertFalse(translator.debug) + + translator.debug = True + self.assertTrue(translator.debug) + + result = translator.translate("common.success") + self.assertEqual(result, "[common.success]") + + def test_global_translator_singleton(self): + """Test that get_translator returns the same instance.""" + from cortex.i18n.translator import get_translator + + t1 = get_translator() + t2 = get_translator() + self.assertIs(t1, t2) + + def test_shorthand_t_function(self): + """Test the shorthand t() function.""" + from cortex.i18n import set_language, t + + # Ensure we're using English for this test + set_language("en") + result = t("common.success") + self.assertEqual(result, "Success") + + def test_set_language_global(self): + """Test set_language function.""" + from cortex.i18n import get_language, set_language + + set_language("es") + self.assertEqual(get_language(), "es") + + set_language("en") + self.assertEqual(get_language(), "en") + + def test_spanish_translations(self): + """Test Spanish translations.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="es") + + self.assertEqual(translator.translate("common.success"), "Éxito") + self.assertEqual(translator.translate("common.error"), "Error") + + def test_french_translations(self): + """Test French translations.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="fr") + + self.assertEqual(translator.translate("common.success"), "Succès") + self.assertEqual(translator.translate("common.error"), "Erreur") + + def test_german_translations(self): + """Test German translations.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="de") + + self.assertEqual(translator.translate("common.success"), "Erfolg") + self.assertEqual(translator.translate("common.error"), "Fehler") + + def test_chinese_translations(self): + """Test Chinese translations.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="zh") + + self.assertEqual(translator.translate("common.success"), "成功") + self.assertEqual(translator.translate("common.error"), "错误") + + def test_get_all_keys(self): + """Test getting all translation keys.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="en") + keys = translator.get_all_keys() + + self.assertIn("common.success", keys) + self.assertIn("common.error", keys) + self.assertIn("install.success", keys) + + def test_get_missing_translations(self): + """Test finding missing translations.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="en") + + # All keys should be present in English (source) + missing = translator.get_missing_translations("en") + self.assertEqual(len(missing), 0) + + +class TestLanguageConfig(unittest.TestCase): + """Tests for language configuration persistence.""" + + def setUp(self): + """Set up test fixtures with temp directory.""" + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_home = Path(self.temp_dir.name) + + # Reset global translator + from cortex.i18n.translator import reset_translator + + reset_translator() + + def tearDown(self): + """Clean up temp directory.""" + self.temp_dir.cleanup() + + from cortex.i18n.translator import reset_translator + + reset_translator() + + def test_malformed_yaml_returns_empty_dict(self): + """Test that malformed YAML in preferences file returns empty dict and doesn't crash.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + + # Create malformed YAML file + prefs_file = self.temp_home / ".cortex" / "preferences.yaml" + prefs_file.parent.mkdir(parents=True, exist_ok=True) + prefs_file.write_text("invalid: yaml: content: [broken") + + # Should not crash, should return default language + lang = config.get_language() + self.assertEqual(lang, "en") + + def test_empty_yaml_file_returns_empty_dict(self): + """Test that empty preferences file returns empty dict.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + + # Create empty file + prefs_file = self.temp_home / ".cortex" / "preferences.yaml" + prefs_file.parent.mkdir(parents=True, exist_ok=True) + prefs_file.write_text("") + + # Should not crash, should return default language + lang = config.get_language() + self.assertEqual(lang, "en") + + def test_whitespace_only_yaml_file_returns_empty_dict(self): + """Test that whitespace-only preferences file returns empty dict.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + + # Create whitespace-only file + prefs_file = self.temp_home / ".cortex" / "preferences.yaml" + prefs_file.parent.mkdir(parents=True, exist_ok=True) + prefs_file.write_text(" \n\t\n ") + + # Should not crash, should return default language + lang = config.get_language() + self.assertEqual(lang, "en") + + def test_invalid_type_in_yaml_returns_empty_dict(self): + """Test that YAML with invalid root type (not dict) returns empty dict.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + + # Create YAML file with list instead of dict + prefs_file = self.temp_home / ".cortex" / "preferences.yaml" + prefs_file.parent.mkdir(parents=True, exist_ok=True) + prefs_file.write_text("- item1\n- item2\n- item3") + + # Should not crash, should return default language + lang = config.get_language() + self.assertEqual(lang, "en") + + def test_yaml_with_string_root_returns_empty_dict(self): + """Test that YAML with string root type returns empty dict.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + + # Create YAML file with just a string + prefs_file = self.temp_home / ".cortex" / "preferences.yaml" + prefs_file.parent.mkdir(parents=True, exist_ok=True) + prefs_file.write_text("just a plain string") + + # Should not crash, should return default language + lang = config.get_language() + self.assertEqual(lang, "en") + + def test_yaml_with_invalid_language_type_uses_default(self): + """Test that YAML with non-string language value uses default.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + + # Create YAML file with integer language + prefs_file = self.temp_home / ".cortex" / "preferences.yaml" + prefs_file.parent.mkdir(parents=True, exist_ok=True) + prefs_file.write_text("language: 123") + + # Should not crash, should return default language + lang = config.get_language() + self.assertEqual(lang, "en") + + def test_yaml_with_null_language_uses_default(self): + """Test that YAML with null language value uses default.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + + # Create YAML file with null language + prefs_file = self.temp_home / ".cortex" / "preferences.yaml" + prefs_file.parent.mkdir(parents=True, exist_ok=True) + prefs_file.write_text("language: null") + + # Should not crash, should return default language + lang = config.get_language() + self.assertEqual(lang, "en") + + def test_get_language_default(self): + """Test default language when no preference is set.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + with patch.dict(os.environ, {}, clear=True): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + lang = config.get_language() + # Should fall back to English since no env var, no config, and no OS detection + self.assertEqual(lang, "en") + + def test_set_and_get_language(self): + """Test setting and getting language preference.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + config.set_language("es") + + # Create new config instance to test persistence + config2 = LanguageConfig() + self.assertEqual(config2.get_language(), "es") + + def test_set_invalid_language_raises(self): + """Test that setting invalid language raises ValueError.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + + with self.assertRaises(ValueError): + config.set_language("invalid") + + def test_env_variable_override(self): + """Test CORTEX_LANGUAGE environment variable takes precedence.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + with patch.dict(os.environ, {"CORTEX_LANGUAGE": "fr"}, clear=True): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + # First set a different language + config.set_language("de") + + # But env var should take precedence + self.assertEqual(config.get_language(), "fr") + + def test_clear_language(self): + """Test clearing language preference.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + with patch.dict(os.environ, {}, clear=True): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + config.set_language("de") + self.assertEqual(config.get_language(), "de") + + config.clear_language() + # Should fall back to default + self.assertEqual(config.get_language(), "en") + + def test_get_language_info(self): + """Test getting detailed language info.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + config.set_language("es") + + info = config.get_language_info() + + self.assertEqual(info["language"], "es") + self.assertEqual(info["source"], "config") + self.assertEqual(info["name"], "Spanish") + self.assertEqual(info["native_name"], "Español") + + +class TestLanguageDetector(unittest.TestCase): + """Tests for OS language auto-detection.""" + + def test_detect_english_from_lang(self): + """Test detection of English from LANG variable.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "en_US.UTF-8"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "en") + + def test_detect_spanish_from_lang(self): + """Test detection of Spanish from LANG variable.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "es_ES.UTF-8"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "es") + + def test_detect_french_from_lang(self): + """Test detection of French from LANG variable.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "fr_FR.UTF-8"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "fr") + + def test_detect_german_from_lang(self): + """Test detection of German from LANG variable.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "de_DE.UTF-8"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "de") + + def test_detect_chinese_from_lang(self): + """Test detection of Chinese from LANG variable.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "zh_CN.UTF-8"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "zh") + + def test_lc_all_takes_precedence(self): + """Test that LC_ALL takes precedence over LANG.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "en_US.UTF-8", "LC_ALL": "fr_FR.UTF-8"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "fr") + + def test_language_variable_takes_precedence(self): + """Test that LANGUAGE takes precedence over LC_ALL.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict( + os.environ, + {"LANGUAGE": "de", "LC_ALL": "fr_FR.UTF-8", "LANG": "en_US.UTF-8"}, + clear=True, + ): + lang = detect_os_language() + self.assertEqual(lang, "de") + + def test_fallback_to_english(self): + """Test fallback to English when no supported language detected.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "ja_JP.UTF-8"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "en") + + def test_c_locale_returns_english(self): + """Test that C locale returns English.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "C"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "en") + + def test_posix_locale_returns_english(self): + """Test that POSIX locale returns English.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {"LANG": "POSIX"}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "en") + + def test_empty_env_returns_english(self): + """Test fallback to English when no env vars set.""" + from cortex.i18n.detector import detect_os_language + + with patch.dict(os.environ, {}, clear=True): + lang = detect_os_language() + self.assertEqual(lang, "en") + + def test_get_os_locale_info(self): + """Test getting OS locale info for debugging.""" + from cortex.i18n.detector import get_os_locale_info + + with patch.dict(os.environ, {"LANG": "en_US.UTF-8", "LC_ALL": ""}, clear=True): + info = get_os_locale_info() + + self.assertIn("LANG", info) + self.assertIn("detected_language", info) + self.assertEqual(info["detected_language"], "en") + + +class TestLocaleFormatter(unittest.TestCase): + """Tests for locale-aware formatting.""" + + def test_format_date_english(self): + """Test date formatting in English locale.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + dt = datetime(2024, 3, 15) + + result = formatter.format_date(dt) + self.assertEqual(result, "2024-03-15") + + def test_format_date_german(self): + """Test date formatting in German locale.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="de") + dt = datetime(2024, 3, 15) + + result = formatter.format_date(dt) + self.assertEqual(result, "15.03.2024") + + def test_format_date_french(self): + """Test date formatting in French locale.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="fr") + dt = datetime(2024, 3, 15) + + result = formatter.format_date(dt) + self.assertEqual(result, "15/03/2024") + + def test_format_number_english(self): + """Test number formatting in English locale.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_number(1234567) + self.assertEqual(result, "1,234,567") + + def test_format_number_german(self): + """Test number formatting in German locale.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="de") + + result = formatter.format_number(1234567) + self.assertEqual(result, "1.234.567") + + def test_format_number_french(self): + """Test number formatting in French locale.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="fr") + + result = formatter.format_number(1234567) + self.assertEqual(result, "1 234 567") + + def test_format_number_with_decimals(self): + """Test number formatting with decimal places.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_number(1234.567, decimals=2) + self.assertEqual(result, "1,234.57") + + def test_format_file_size_bytes(self): + """Test file size formatting for bytes.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_file_size(500) + self.assertEqual(result, "500 B") + + def test_format_file_size_kb(self): + """Test file size formatting for kilobytes.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_file_size(1536) + self.assertIn("KB", result) + + def test_format_file_size_mb(self): + """Test file size formatting for megabytes.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_file_size(1536 * 1024) + self.assertIn("MB", result) + + def test_format_file_size_gb(self): + """Test file size formatting for gigabytes.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_file_size(2 * 1024 * 1024 * 1024) + self.assertIn("GB", result) + + def test_format_time_ago_just_now(self): + """Test relative time for just now.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + now = datetime.now() + + result = formatter.format_time_ago(now, now) + self.assertEqual(result, "just now") + + def test_format_time_ago_seconds(self): + """Test relative time for seconds.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + now = datetime.now() + past = now - timedelta(seconds=30) + + result = formatter.format_time_ago(past, now) + self.assertIn("seconds ago", result) + + def test_format_time_ago_minutes(self): + """Test relative time for minutes.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + now = datetime.now() + past = now - timedelta(minutes=5) + + result = formatter.format_time_ago(past, now) + self.assertIn("minutes ago", result) + + def test_format_time_ago_hours(self): + """Test relative time for hours.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + now = datetime.now() + past = now - timedelta(hours=3) + + result = formatter.format_time_ago(past, now) + self.assertIn("hours ago", result) + + def test_format_time_ago_spanish(self): + """Test relative time in Spanish.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="es") + now = datetime.now() + past = now - timedelta(minutes=5) + + result = formatter.format_time_ago(past, now) + self.assertIn("hace", result) + self.assertIn("minutos", result) + + def test_format_time_ago_chinese(self): + """Test relative time in Chinese.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="zh") + now = datetime.now() + past = now - timedelta(minutes=5) + + result = formatter.format_time_ago(past, now) + self.assertIn("分钟前", result) + + def test_format_duration_milliseconds(self): + """Test duration formatting for milliseconds.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_duration(0.5) + self.assertIn("ms", result) + + def test_format_duration_seconds(self): + """Test duration formatting for seconds.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_duration(45.2) + self.assertIn("s", result) + self.assertIn("45", result) + + def test_format_duration_minutes(self): + """Test duration formatting for minutes.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_duration(150) # 2m 30s + self.assertIn("m", result) + + def test_format_duration_hours(self): + """Test duration formatting for hours.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + + result = formatter.format_duration(3700) # 1h 1m + self.assertIn("h", result) + + def test_formatter_language_setter(self): + """Test setting language on formatter.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="en") + self.assertEqual(formatter.language, "en") + + formatter.language = "de" + self.assertEqual(formatter.language, "de") + + def test_formatter_invalid_language_fallback(self): + """Test that invalid language falls back to English.""" + from cortex.i18n.formatter import LocaleFormatter + + formatter = LocaleFormatter(language="invalid") + self.assertEqual(formatter.language, "en") + + +class TestSupportedLanguages(unittest.TestCase): + """Tests for supported languages metadata.""" + + def test_all_supported_languages_have_catalogs(self): + """Test that all supported languages have message catalogs.""" + import importlib.resources + + from cortex.i18n.translator import SUPPORTED_LANGUAGES + + # Use importlib.resources for robust path resolution that works + # regardless of how the package is installed or test is invoked + try: + # Python 3.9+ API + locales_pkg = importlib.resources.files("cortex.i18n") / "locales" + for lang_code in SUPPORTED_LANGUAGES: + catalog_file = locales_pkg / f"{lang_code}.yaml" + # Check if the resource exists using is_file() or traversable API + self.assertTrue( + catalog_file.is_file(), + f"Missing catalog for language: {lang_code}", + ) + except (TypeError, AttributeError): + # Fallback for older Python versions + from pathlib import Path + + locales_dir = Path(__file__).parent.parent / "cortex" / "i18n" / "locales" + for lang_code in SUPPORTED_LANGUAGES: + catalog_path = locales_dir / f"{lang_code}.yaml" + self.assertTrue(catalog_path.exists(), f"Missing catalog for language: {lang_code}") + + def test_all_languages_have_metadata(self): + """Test that all supported languages have name and native fields.""" + from cortex.i18n.translator import SUPPORTED_LANGUAGES + + for lang_code, info in SUPPORTED_LANGUAGES.items(): + self.assertIn("name", info, f"Missing 'name' for {lang_code}") + self.assertIn("native", info, f"Missing 'native' for {lang_code}") + + def test_required_languages_supported(self): + """Test that all required languages are supported.""" + from cortex.i18n.translator import SUPPORTED_LANGUAGES + + required = ["en", "es", "fr", "de", "zh"] + for lang in required: + self.assertIn(lang, SUPPORTED_LANGUAGES) + + +class TestTranslationCompleteness(unittest.TestCase): + """Tests to verify translation completeness.""" + + def test_all_languages_have_common_keys(self): + """Test that all languages have the common keys.""" + from cortex.i18n.translator import SUPPORTED_LANGUAGES, Translator + + translator = Translator(language="en") + en_keys = translator.get_all_keys("en") + + common_keys = [ + "common.success", + "common.error", + "common.warning", + "install.success", + "language.changed", + ] + + for lang in SUPPORTED_LANGUAGES: + translator.language = lang + for key in common_keys: + result = translator.translate(key) + self.assertNotEqual(result, key, f"Key '{key}' not translated in {lang}") + + def test_no_english_leaks_in_spanish(self): + """Test that Spanish doesn't have English strings for key messages.""" + from cortex.i18n.translator import Translator + + translator = Translator(language="es") + + # These should NOT be English + success = translator.translate("common.success") + self.assertNotEqual(success, "Success") + self.assertEqual(success, "Éxito") + + def test_variable_interpolation_all_languages(self): + """Test that variable interpolation works in all languages.""" + from cortex.i18n.translator import SUPPORTED_LANGUAGES, Translator + + for lang in SUPPORTED_LANGUAGES: + translator = Translator(language=lang) + result = translator.translate("language.changed", language="Test") + self.assertIn("Test", result, f"Interpolation failed for {lang}") + self.assertNotIn("{language}", result, f"Variable not replaced in {lang}") + + +class TestCLIIntegration(unittest.TestCase): + """Tests for CLI integration with i18n.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_home = Path(self.temp_dir.name) + + from cortex.i18n.translator import reset_translator + + reset_translator() + + def tearDown(self): + """Clean up after tests.""" + self.temp_dir.cleanup() + + from cortex.i18n.translator import reset_translator + + reset_translator() + + def test_cli_language_list(self): + """Test CLI language list command.""" + from unittest.mock import patch + + with patch("pathlib.Path.home", return_value=self.temp_home): + import argparse + + from cortex.cli import CortexCLI + + cli = CortexCLI() + args = argparse.Namespace(config_action="language", list=True, info=False, code=None) + + result = cli.config(args) + self.assertEqual(result, 0) + + def test_cli_language_set(self): + """Test CLI language set command.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + import argparse + + from cortex.cli import CortexCLI + + cli = CortexCLI() + args = argparse.Namespace(config_action="language", list=False, info=False, code="es") + + result = cli.config(args) + self.assertEqual(result, 0) + + # Verify language was set + from cortex.i18n.config import LanguageConfig + + config = LanguageConfig() + self.assertEqual(config.get_language(), "es") + + def test_cli_language_invalid(self): + """Test CLI with invalid language code.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + import argparse + + from cortex.cli import CortexCLI + + cli = CortexCLI() + args = argparse.Namespace( + config_action="language", list=False, info=False, code="invalid" + ) + + result = cli.config(args) + self.assertEqual(result, 1) + + def test_cli_language_auto(self): + """Test CLI language auto detection.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + with patch.dict(os.environ, {}, clear=True): + import argparse + + from cortex.cli import CortexCLI + + cli = CortexCLI() + + # First set a language + args = argparse.Namespace( + config_action="language", list=False, info=False, code="de" + ) + cli.config(args) + + # Then set to auto + args = argparse.Namespace( + config_action="language", list=False, info=False, code="auto" + ) + result = cli.config(args) + self.assertEqual(result, 0) + + +class TestGlobalFunctions(unittest.TestCase): + """Tests for global convenience functions.""" + + def setUp(self): + """Reset translator before each test.""" + from cortex.i18n.translator import reset_translator + + reset_translator() + + def tearDown(self): + """Reset translator after each test.""" + from cortex.i18n.translator import reset_translator + + reset_translator() + + def test_format_functions(self): + """Test global format functions.""" + from cortex.i18n.formatter import ( + format_date, + format_datetime, + format_duration, + format_file_size, + format_number, + format_time, + ) + + dt = datetime(2024, 3, 15, 14, 30) + + self.assertIsInstance(format_date(dt), str) + self.assertIsInstance(format_time(dt), str) + self.assertIsInstance(format_datetime(dt), str) + self.assertIsInstance(format_number(1234), str) + self.assertIsInstance(format_file_size(1024), str) + self.assertIsInstance(format_duration(60), str) + + +class TestResolveLanguageName(unittest.TestCase): + """Tests for the _resolve_language_name function.""" + + def test_resolve_language_code(self): + """Test resolving language codes.""" + from cortex.cli import _resolve_language_name + + self.assertEqual(_resolve_language_name("en"), "en") + self.assertEqual(_resolve_language_name("es"), "es") + self.assertEqual(_resolve_language_name("fr"), "fr") + self.assertEqual(_resolve_language_name("de"), "de") + self.assertEqual(_resolve_language_name("zh"), "zh") + + def test_resolve_english_names(self): + """Test resolving English language names.""" + from cortex.cli import _resolve_language_name + + self.assertEqual(_resolve_language_name("English"), "en") + self.assertEqual(_resolve_language_name("Spanish"), "es") + self.assertEqual(_resolve_language_name("French"), "fr") + self.assertEqual(_resolve_language_name("German"), "de") + self.assertEqual(_resolve_language_name("Chinese"), "zh") + + def test_resolve_native_names(self): + """Test resolving native language names.""" + from cortex.cli import _resolve_language_name + + self.assertEqual(_resolve_language_name("Español"), "es") + self.assertEqual(_resolve_language_name("Français"), "fr") + self.assertEqual(_resolve_language_name("Deutsch"), "de") + self.assertEqual(_resolve_language_name("中文"), "zh") + + def test_resolve_case_insensitive(self): + """Test case-insensitive resolution.""" + from cortex.cli import _resolve_language_name + + self.assertEqual(_resolve_language_name("ENGLISH"), "en") + self.assertEqual(_resolve_language_name("spanish"), "es") + self.assertEqual(_resolve_language_name("FRENCH"), "fr") + self.assertEqual(_resolve_language_name("german"), "de") + + def test_resolve_invalid_returns_none(self): + """Test that invalid language names return None.""" + from cortex.cli import _resolve_language_name + + self.assertIsNone(_resolve_language_name("Japanese")) + self.assertIsNone(_resolve_language_name("invalid")) + self.assertIsNone(_resolve_language_name("")) + + def test_resolve_non_latin_scripts_no_collision(self): + """Test that non-Latin scripts don't create key collisions.""" + from cortex.cli import _resolve_language_name + + # Chinese should resolve correctly + self.assertEqual(_resolve_language_name("中文"), "zh") + + # Different non-Latin strings should not collide + self.assertIsNone(_resolve_language_name("日本語")) # Japanese + self.assertIsNone(_resolve_language_name("한국어")) # Korean + + def test_resolve_mixed_case_non_latin(self): + """Test that non-Latin scripts are matched exactly.""" + from cortex.cli import _resolve_language_name + + # Non-Latin script should work exactly as-is + self.assertEqual(_resolve_language_name("中文"), "zh") + + def test_resolve_with_whitespace(self): + """Test that whitespace is handled.""" + from cortex.cli import _resolve_language_name + + self.assertEqual(_resolve_language_name(" English "), "en") + self.assertEqual(_resolve_language_name(" es "), "es") + + +class TestSetLanguageFlag(unittest.TestCase): + """Tests for the --set-language CLI flag.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_home = Path(self.temp_dir.name) + + from cortex.i18n.translator import reset_translator + + reset_translator() + + def tearDown(self): + """Clean up after tests.""" + self.temp_dir.cleanup() + + from cortex.i18n.translator import reset_translator + + reset_translator() + + def test_set_language_flag_with_english_name(self): + """Test --set-language with English language name.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.cli import _handle_set_language + + result = _handle_set_language("Spanish") + self.assertEqual(result, 0) + + from cortex.i18n import get_language + + self.assertEqual(get_language(), "es") + + def test_set_language_flag_with_native_name(self): + """Test --set-language with native language name.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.cli import _handle_set_language + + result = _handle_set_language("Français") + self.assertEqual(result, 0) + + from cortex.i18n import get_language + + self.assertEqual(get_language(), "fr") + + def test_set_language_flag_with_code(self): + """Test --set-language with language code.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.cli import _handle_set_language + + result = _handle_set_language("de") + self.assertEqual(result, 0) + + from cortex.i18n import get_language + + self.assertEqual(get_language(), "de") + + def test_set_language_flag_invalid(self): + """Test --set-language with invalid language.""" + with patch("pathlib.Path.home", return_value=self.temp_home): + from cortex.cli import _handle_set_language + + result = _handle_set_language("InvalidLanguage") + self.assertEqual(result, 1) + + +if __name__ == "__main__": + unittest.main()