fix(i18n): resolve --config language, translate argparse built-ins and example titles

This commit is contained in:
criss717 2026-08-24 23:05:53 +02:00
parent 27cf85f849
commit b7a4a29264
5 changed files with 207 additions and 26 deletions

View file

@ -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()

View file

@ -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://<collection-uuid> --target https://api.example.com
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
# 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
""",

View file

@ -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.",

View file

@ -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.",

View file

@ -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: