From b7a4a29264d2be6a3ff7a58917ebc12b49f5d04e Mon Sep 17 00:00:00 2001 From: criss717 Date: Mon, 24 Aug 2026 23:05:53 +0200 Subject: [PATCH] fix(i18n): resolve --config language, translate argparse built-ins and example titles --- strix/i18n.py | 16 ++++++- strix/interface/cli_args.py | 84 ++++++++++++++++++++++---------- strix/locales/en.json | 19 ++++++++ strix/locales/es.json | 19 ++++++++ tests/test_i18n.py | 95 +++++++++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 26 deletions(-) diff --git a/strix/i18n.py b/strix/i18n.py index 74a2881a..55b62e1b 100644 --- a/strix/i18n.py +++ b/strix/i18n.py @@ -17,6 +17,7 @@ SUPPORTED_LANGUAGES: frozenset[str] = frozenset({"en", "es"}) # Module-level state _language: str | None = None +_config_path: Path | None = None _locales: dict[str, dict[str, str]] = {} _lock = threading.Lock() _locales_dir: Path = Path(__file__).parent / "locales" @@ -28,7 +29,7 @@ def _detect_language() -> str: Priority: 1. _language (set by --language CLI flag or set_language()) 2. STRIX_LANGUAGE env var - 3. ~/.strix/cli-config.json "language" field + 3. Config file (--config override or ~/.strix/cli-config.json) 4. LANG / LC_ALL system locale (first 2 chars) 5. "en" default """ @@ -43,7 +44,7 @@ def _detect_language() -> str: # 3. Config file (canonical format: {"env": {"STRIX_LANGUAGE": "es"}}) try: - config_path = Path.home() / ".strix" / "cli-config.json" + config_path = _config_path or (Path.home() / ".strix" / "cli-config.json") if config_path.exists(): data = json.loads(config_path.read_text(encoding="utf-8")) if isinstance(data, dict): @@ -104,6 +105,17 @@ def set_language(lang: str | None) -> None: _language = _normalize_lang(lang) if lang else None +def set_config_path(path: str | Path | None) -> None: + """Override the config file used for language resolution. + + ``--config`` selects a custom config file; its ``env.STRIX_LANGUAGE`` + should drive localization just like the default ``~/.strix/cli-config.json``. + Pass ``None`` to clear the override. + """ + global _config_path # noqa: PLW0603 + _config_path = Path(path) if path else None + + def get_language() -> str: """Get the currently resolved language.""" return _detect_language() diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index 2cbae624..f20a0c53 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -20,23 +20,59 @@ from strix.interface.utils import ( ) -def _pre_resolve_language() -> None: - """Set language from --language/-l before argparse runs. +# Translate argparse's built-in strings (usage, options, error, help/version) +# through the i18n layer. argparse routes these through ``gettext.gettext``, +# so replacing ``argparse._`` with a ``t()``-backed lookup localizes them. +_ARGPARSE_MESSAGE_KEYS: dict[str, str] = { + "usage: ": "cli.argparse_usage", + "options": "cli.argparse_options", + "show this help message and exit": "cli.argparse_help", + "show program's version number and exit": "cli.argparse_version", + "%(prog)s: error: %(message)s\n": "cli.argparse_error", + "unrecognized arguments: %s": "cli.argparse_unrecognized", + "the following arguments are required: %s": "cli.argparse_required", +} - Argparse evaluates help text at parse time, so we must set the language - BEFORE parse_args() is called. This pre-scans sys.argv for the flag. + +def _translate_argparse(message: str) -> str: + """Return the localized form of an argparse built-in string.""" + key = _ARGPARSE_MESSAGE_KEYS.get(message) + if key is None: + return message + translated = t(key) + return translated if translated != key else message + + +argparse._ = _translate_argparse # type: ignore[attr-defined] + + +def _pre_resolve_language() -> None: + """Resolve language from --language/-l and --config before argparse runs. + + Argparse evaluates help text at parse time, so we must resolve the language + BEFORE parse_args() is called. This pre-scans sys.argv for the flags. + Priority matches ``_detect_language``: --language > STRIX_LANGUAGE env > + --config file > default config > system locale. """ argv = sys.argv[1:] + language: str | None = None + config_path: str | None = None for i, arg in enumerate(argv): if arg in ("-l", "--language") and i + 1 < len(argv): - from strix.i18n import set_language - set_language(argv[i + 1]) - return - # Handle --language=es form - if arg.startswith("--language="): - from strix.i18n import set_language - set_language(arg.split("=", 1)[1]) - return + language = argv[i + 1] + elif arg.startswith("--language="): + language = arg.split("=", 1)[1] + elif arg == "--config" and i + 1 < len(argv): + config_path = argv[i + 1] + elif arg.startswith("--config="): + config_path = arg.split("=", 1)[1] + + from strix.i18n import set_config_path, set_language + + if language: + set_language(language) + elif config_path: + set_config_path(config_path) def get_version() -> str: @@ -79,45 +115,45 @@ def parse_arguments() -> argparse.Namespace: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f""" {t("cli.examples_header")} - # Web application penetration test + # {t("cli.example_web_app")} strix --target https://example.com - # GitHub repository analysis + # {t("cli.example_github")} strix --target https://github.com/user/repo strix --target git@github.com:user/repo.git - # Local code analysis + # {t("cli.example_local_code")} strix --target ./my-project - # API spec test (OpenAPI/Swagger file or Postman collection export) + # {t("cli.example_api_spec")} strix --target ./openapi.yaml --target https://api.example.com strix --target ./collection.postman_collection.json - # Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment + # {t("cli.example_postman")} strix --target postman:// --target https://api.example.com strix --target "postman://?env=" - # Domain penetration test + # {t("cli.example_domain")} strix --target example.com - # IP address penetration test + # {t("cli.example_ip")} strix --target 192.168.1.42 - # Multiple targets (e.g., white-box testing with source and deployed app) + # {t("cli.example_multiple")} strix --target https://github.com/user/repo --target https://example.com strix --target ./my-project --target https://staging.example.com --target https://prod.example.com - # Targets from a file, one target per non-empty, non-comment line + # {t("cli.example_file")} strix --target-list ./targets.txt - # Custom instructions (inline) + # {t("cli.example_instruction_inline")} strix --target example.com --instruction "Focus on authentication vulnerabilities" - # Custom instructions (from file) + # {t("cli.example_instruction_file")} strix --target example.com --instruction-file ./instructions.txt strix --target https://app.com --instruction-file /path/to/detailed_instructions.md - # Extra files placed in the sandbox workspace + # {t("cli.example_workspace")} strix --target ./my-project --workspace-file ./wordlist.txt strix --target https://app.com --workspace-file ./openapi.yaml:specs/openapi.yaml """, diff --git a/strix/locales/en.json b/strix/locales/en.json index db10937a..dde20b01 100644 --- a/strix/locales/en.json +++ b/strix/locales/en.json @@ -15,6 +15,25 @@ "cli.language_help": "Language for UI and agent responses (e.g., 'en', 'es'). Default: auto-detect from environment.", "cli.workspace_file_help": "Place a file from this machine into the sandbox workspace before the scan starts, for example a wordlist, an API specification, or notes. Repeat the option for more files. DEST is the path inside /workspace and defaults to the file name (for example '--workspace-file ./wordlist.txt:lists/wordlist.txt'). The file is read-only inside the sandbox and lands outside every target directory.", "cli.examples_header": "Examples:", + "cli.argparse_usage": "usage: ", + "cli.argparse_options": "options", + "cli.argparse_help": "show this help message and exit", + "cli.argparse_version": "show program's version number and exit", + "cli.argparse_error": "%(prog)s: error: %(message)s\n", + "cli.argparse_unrecognized": "unrecognized arguments: %s", + "cli.argparse_required": "the following arguments are required: %s", + "cli.example_web_app": "Web application penetration test", + "cli.example_github": "GitHub repository analysis", + "cli.example_local_code": "Local code analysis", + "cli.example_api_spec": "API spec test (OpenAPI/Swagger file or Postman collection export)", + "cli.example_postman": "Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment", + "cli.example_domain": "Domain penetration test", + "cli.example_ip": "IP address penetration test", + "cli.example_multiple": "Multiple targets (e.g., white-box testing with source and deployed app)", + "cli.example_file": "Targets from a file, one target per non-empty, non-comment line", + "cli.example_instruction_inline": "Custom instructions (inline)", + "cli.example_instruction_file": "Custom instructions (from file)", + "cli.example_workspace": "Extra files placed in the sandbox workspace", "cli.update_help": "Update strix to the latest version and exit. Self-updates the standalone binary install; for pip/pipx/uv installs, prints the matching upgrade command instead.", "cli.version_help": "Show version and exit.", "cli.error_no_target": "No target specified. Use --target or --target-list.", diff --git a/strix/locales/es.json b/strix/locales/es.json index 0f0fa4ce..1f50a42a 100644 --- a/strix/locales/es.json +++ b/strix/locales/es.json @@ -15,6 +15,25 @@ "cli.language_help": "Idioma para la interfaz y respuestas de agentes (ej: 'en', 'es'). Por defecto: auto-detectar del entorno.", "cli.workspace_file_help": "Coloca un archivo de esta máquina en el workspace del sandbox antes de que comience el escaneo, por ejemplo una wordlist, una especificación de API o notas. Repite la opción para más archivos. DEST es la ruta dentro de /workspace y por defecto usa el nombre del archivo (por ejemplo '--workspace-file ./wordlist.txt:lists/wordlist.txt'). El archivo es de solo lectura dentro del sandbox y queda fuera de cada directorio de objetivo.", "cli.examples_header": "Ejemplos:", + "cli.argparse_usage": "uso: ", + "cli.argparse_options": "opciones", + "cli.argparse_help": "muestra este mensaje de ayuda y sale", + "cli.argparse_version": "muestra el número de versión del programa y sale", + "cli.argparse_error": "%(prog)s: error: %(message)s\n", + "cli.argparse_unrecognized": "argumentos no reconocidos: %s", + "cli.argparse_required": "se requieren los siguientes argumentos: %s", + "cli.example_web_app": "Prueba de penetración de aplicación web", + "cli.example_github": "Análisis de repositorio de GitHub", + "cli.example_local_code": "Análisis de código local", + "cli.example_api_spec": "Prueba de spec API (archivo OpenAPI/Swagger o export de colección Postman)", + "cli.example_postman": "Colección Postman obtenida en vivo por id (requiere POSTMAN_API_KEY); entorno opcional", + "cli.example_domain": "Prueba de penetración de dominio", + "cli.example_ip": "Prueba de penetración de dirección IP", + "cli.example_multiple": "Múltiples objetivos (ej., prueba white-box con código fuente y app desplegada)", + "cli.example_file": "Objetivos desde un archivo, uno por línea no vacía y no comentario", + "cli.example_instruction_inline": "Instrucciones personalizadas (en línea)", + "cli.example_instruction_file": "Instrucciones personalizadas (desde archivo)", + "cli.example_workspace": "Archivos extra colocados en el workspace del sandbox", "cli.update_help": "Actualizar strix a la última versión y salir. Auto-actualiza la instalación binaria; para instalaciones pip/pipx/uv, muestra el comando de actualización correspondiente.", "cli.version_help": "Mostrar versión y salir.", "cli.error_no_target": "No se especificó objetivo. Use --target o --target-list.", diff --git a/tests/test_i18n.py b/tests/test_i18n.py index ff8df30d..41331d71 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import json import os from pathlib import Path @@ -18,18 +19,22 @@ from strix.i18n import ( _normalize_lang, get_language, get_language_directive, + set_config_path, set_language, t, ) +from strix.interface import cli_args @pytest.fixture(autouse=True) def _reset_i18n_state(): """Reset module-level state between tests.""" mod._language = None + mod._config_path = None mod._locales.clear() yield mod._language = None + mod._config_path = None mod._locales.clear() @@ -250,6 +255,96 @@ class TestCliHelpKeys: es_value = t("cli.unknown_model_hint") assert "por ejemplo" in es_value + def test_example_titles_translate(self): + set_language("es") + assert "aplicación web" in t("cli.example_web_app") + assert "repositorio" in t("cli.example_github") + assert "código local" in t("cli.example_local_code") + assert "spec API" in t("cli.example_api_spec") + assert "dirección IP" in t("cli.example_ip") + assert "archivo" in t("cli.example_file") + set_language("en") + assert t("cli.example_web_app") == "Web application penetration test" + + +class TestCustomConfigPath: + """``--config`` should drive language resolution like the default config.""" + + def test_custom_config_path_resolves_language(self, tmp_path): + config = tmp_path / "custom.json" + config.write_text( + json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8" + ) + set_config_path(config) + assert get_language() == "es" + + def test_custom_config_path_ignored_without_env_key(self, tmp_path): + config = tmp_path / "custom.json" + config.write_text(json.dumps({"env": {}}), encoding="utf-8") + set_config_path(config) + with patch.dict(os.environ, {}, clear=True): + assert get_language() == "en" + + def test_custom_config_missing_file_falls_back(self, tmp_path): + set_config_path(tmp_path / "does-not-exist.json") + with patch.dict(os.environ, {}, clear=True): + assert get_language() == "en" + + def test_env_var_overrides_custom_config(self, tmp_path): + config = tmp_path / "custom.json" + config.write_text( + json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8" + ) + set_config_path(config) + with patch.dict(os.environ, {"STRIX_LANGUAGE": "en"}): + assert get_language() == "en" + + def test_explicit_language_overrides_custom_config(self, tmp_path): + config = tmp_path / "custom.json" + config.write_text( + json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8" + ) + set_config_path(config) + set_language("en") + assert get_language() == "en" + + +class TestArgparseBuiltins: + """argparse's built-in strings must translate through the i18n layer.""" + + def test_usage_translates(self): + set_language("es") + assert t("cli.argparse_usage") == "uso: " + set_language("en") + assert t("cli.argparse_usage") == "usage: " + + def test_options_translates(self): + set_language("es") + assert t("cli.argparse_options") == "opciones" + set_language("en") + assert t("cli.argparse_options") == "options" + + def test_help_help_translates(self): + set_language("es") + assert t("cli.argparse_help") == "muestra este mensaje de ayuda y sale" + + def test_version_help_translates(self): + set_language("es") + assert t("cli.argparse_version") == ( + "muestra el número de versión del programa y sale" + ) + + def test_unrecognized_arguments_translates(self): + set_language("es") + assert t("cli.argparse_unrecognized") == "argumentos no reconocidos: %s" + + def test_argparse_override_is_wired(self): + # The override routes argparse's gettext calls through t(). + set_language("es") + assert cli_args._translate_argparse("usage: ") == "uso: " + assert cli_args._translate_argparse("options") == "opciones" + assert cli_args._translate_argparse("unknown string") == "unknown string" + assert argparse._("usage: ") == "uso: " class TestSettingsLanguageField: