ragfly-cli 1.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ """
2
+ Comandos cloud — cliente HTTP contra la API REST de Railway.
3
+
4
+ API pública:
5
+ obtener_token() → str (lee JWT o lanza RuntimeError)
6
+ cloud_get(path, **kw) → dict (GET autenticado)
7
+ cloud_post(path, **kw) → dict (POST autenticado)
8
+ CLOUD_URL → str
9
+
10
+ Clases auxiliares:
11
+ CloudError — error de red/API con exit_code
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+ from . import keyring_store
24
+
25
+ # ── Constantes ───────────────────────────────────────────────────────────────
26
+
27
+ CLOUD_URL = "https://api.ragfly.ai"
28
+ LEGACY_CREDENTIALS_PATH = Path.home() / ".ragfly" / "credentials.json"
29
+
30
+
31
+ # ── Excepciones ───────────────────────────────────────────────────────────────
32
+
33
+ class CloudError(Exception):
34
+ """Error de red o de la API cloud."""
35
+
36
+ def __init__(self, mensaje: str, exit_code: int = 2):
37
+ super().__init__(mensaje)
38
+ self.exit_code = exit_code
39
+
40
+
41
+ # ── Auth ──────────────────────────────────────────────────────────────────────
42
+
43
+ def _migrar_legacy_json_a_keyring() -> str | None:
44
+ """Si existe ~/.ragfly/credentials.json (v1.0.x), lo migra al keyring
45
+ del SO y lo borra. Retorna el token migrado, o None si no había."""
46
+ if not LEGACY_CREDENTIALS_PATH.exists():
47
+ return None
48
+ try:
49
+ creds = json.loads(LEGACY_CREDENTIALS_PATH.read_text())
50
+ token = creds.get("access_token", "")
51
+ email = creds.get("email", "")
52
+ if token:
53
+ keyring_store.guardar(token, email)
54
+ LEGACY_CREDENTIALS_PATH.unlink()
55
+ return token or None
56
+ except Exception:
57
+ return None
58
+
59
+
60
+ def obtener_token() -> str:
61
+ """Resuelve el token de autenticación, en orden de precedencia:
62
+
63
+ 1. ``RAGFLY_TOKEN`` del entorno — API key (``slm_live_...``) o JWT. Pensado
64
+ para CI/automatización headless, donde no hay keyring del SO.
65
+ 2. El JWT guardado en el keyring del SO por ``ragfly login``.
66
+
67
+ La validez se delega al backend: cualquier 401 dispara logout en el shell.
68
+ """
69
+ env_token = os.environ.get("RAGFLY_TOKEN", "").strip()
70
+ if env_token:
71
+ return env_token
72
+ token = keyring_store.leer_token() or _migrar_legacy_json_a_keyring()
73
+ if not token:
74
+ raise CloudError(
75
+ "No has iniciado sesión. Ejecutá `ragfly login` o exportá RAGFLY_TOKEN.",
76
+ exit_code=1,
77
+ )
78
+ return token
79
+
80
+
81
+ def guardar_credenciales(token: str, email: str, expires_in: int = 3600) -> None:
82
+ """Guarda JWT en el keyring del SO. `expires_in` se ignora (firma compat)."""
83
+ keyring_store.guardar(token, email)
84
+
85
+
86
+ def borrar_credenciales() -> None:
87
+ """Borra JWT del keyring (logout). Limpia también el JSON legacy si quedó."""
88
+ keyring_store.borrar()
89
+ try:
90
+ if LEGACY_CREDENTIALS_PATH.exists():
91
+ LEGACY_CREDENTIALS_PATH.unlink()
92
+ except Exception:
93
+ pass
94
+
95
+
96
+ def ya_esta_logueado() -> bool:
97
+ """True si hay token en el keyring (no valida vivo — eso lo hace /auth/me)."""
98
+ try:
99
+ obtener_token()
100
+ return True
101
+ except CloudError:
102
+ return False
103
+
104
+
105
+ def cargar_contexto_actual() -> dict:
106
+ """Llama a /auth/me y retorna el contexto del usuario logueado."""
107
+ return cloud_get("/auth/me")
108
+
109
+
110
+ # ── HTTP helpers ─────────────────────────────────────────────────────────────
111
+
112
+ def _headers(token: str | None = None) -> dict[str, str]:
113
+ """Headers estándar para cualquier request al cloud.
114
+
115
+ Incluye X-Client-Version desde ragfly.__version__ — backend lo loggea
116
+ siempre y en el futuro puede rechazar versiones incompatibles (gated por
117
+ env ENFORCE_CLIENT_VERSION en backend).
118
+ """
119
+ from ragfly_cli import __version__ as _client_version
120
+
121
+ t = token or obtener_token()
122
+ return {
123
+ "Authorization": f"Bearer {t}",
124
+ "Content-Type": "application/json",
125
+ "X-Client-Version": _client_version,
126
+ }
127
+
128
+
129
+ def cloud_get(
130
+ path: str,
131
+ params: dict | None = None,
132
+ token: str | None = None,
133
+ timeout: int = 30,
134
+ ) -> Any:
135
+ """GET autenticado contra CLOUD_URL/path. Retorna JSON parseado.
136
+
137
+ Wrapper retrocompatible sobre `CloudHttpClient` (ragfly.oop).
138
+ """
139
+ from ragfly_cli.oop import CloudHttpClient
140
+ return CloudHttpClient(timeout_get=timeout).get(path, params=params, token=token)
141
+
142
+
143
+ def cloud_post(
144
+ path: str,
145
+ body: dict | None = None,
146
+ params: dict | None = None,
147
+ token: str | None = None,
148
+ timeout: int = 60,
149
+ ) -> Any:
150
+ """POST autenticado contra CLOUD_URL/path. Retorna JSON parseado.
151
+
152
+ Wrapper retrocompatible sobre `CloudHttpClient` (ragfly.oop).
153
+ """
154
+ from ragfly_cli.oop import CloudHttpClient
155
+ return CloudHttpClient(timeout_write=timeout).post(path, body=body, params=params, token=token)
156
+
157
+
158
+ def _manejar_http_error(e: httpx.HTTPStatusError) -> None:
159
+ status = e.response.status_code
160
+ try:
161
+ detalle = e.response.json().get("detail", str(e))
162
+ except Exception:
163
+ detalle = e.response.text[:200] or str(e)
164
+
165
+ if status == 401:
166
+ raise CloudError("No autorizado. Ejecuta: ragfly login", exit_code=1)
167
+ if status == 403:
168
+ raise CloudError(f"Sin permisos: {detalle}", exit_code=1)
169
+ if status == 404:
170
+ raise CloudError(f"No encontrado: {detalle}", exit_code=1)
171
+ if status == 422:
172
+ raise CloudError(f"Datos inválidos: {detalle}", exit_code=1)
173
+ raise CloudError(f"Error {status}: {detalle}", exit_code=2)
174
+
175
+
176
+ # ── Login helper ─────────────────────────────────────────────────────────────
177
+
178
+ def login(email: str, password: str) -> dict:
179
+ """Autentica contra /auth/login y retorna el contexto del usuario."""
180
+ try:
181
+ r = httpx.post(
182
+ f"{CLOUD_URL}/auth/login",
183
+ json={"email": email, "password": password},
184
+ timeout=30,
185
+ )
186
+ r.raise_for_status()
187
+ data = r.json()
188
+ except httpx.HTTPStatusError as e:
189
+ if e.response.status_code in (401, 403):
190
+ raise CloudError("Email o contraseña incorrectos.", exit_code=1)
191
+ _manejar_http_error(e)
192
+ except httpx.RequestError as e:
193
+ raise CloudError(f"No se pudo conectar al servidor: {e}", exit_code=2)
194
+
195
+ token = data.get("access_token") or data.get("token", "")
196
+ if not token:
197
+ raise CloudError("El servidor no retornó un token.", exit_code=2)
198
+
199
+ expires_in = data.get("expires_in", 3600)
200
+ guardar_credenciales(token, email, expires_in)
201
+ return data
ragfly_cli/config.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ Configuración del cliente RAGfly.
3
+
4
+ Lee de ~/.ragfly/config.env o variables de entorno.
5
+ Patrón idéntico al backend (pydantic-settings + lru_cache).
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from functools import lru_cache
11
+ from pydantic_settings import BaseSettings
12
+
13
+
14
+ def _get_home() -> Path:
15
+ """Retorna el directorio home de RAGfly (evaluado en runtime)."""
16
+ return Path(os.environ.get("RAGFLY_HOME", Path.home() / ".ragfly"))
17
+
18
+
19
+ # Directorio base de datos del cliente (evaluado al importar, útil como default)
20
+ RAGFLY_HOME = _get_home()
21
+
22
+
23
+ class ClienteConfig(BaseSettings):
24
+ """Configuración del cliente local."""
25
+
26
+ # --- Conexión al Cloud ---
27
+ cloud_url: str = ""
28
+ email: str = ""
29
+ password: str = "" # TODO: encriptar con keyring en futuras versiones
30
+
31
+ # --- Tokens OAuth ---
32
+ # Si están presentes, `sync.py` los usa en vez de email/password para autenticar.
33
+ # NOTA: el flujo OAuth Authorization Code → localhost redirect que los poblaba
34
+ # vivía en `api_local.py` (servidor :27182), retirado 2026-06-19 (legado D.1).
35
+ # El SSO del Desktop GUI va por otro camino (auth_bridge → keyring del SO). El
36
+ # CLI puro autentica por email/password salvo que estos tokens se seteen aparte.
37
+ access_token: str = ""
38
+ refresh_token: str = ""
39
+
40
+ # --- Contexto multi-tenant ---
41
+ codigo_grupo: str = ""
42
+ codigo_entidad: str = ""
43
+
44
+ # --- Rutas locales ---
45
+ directorio_documentos: str = ""
46
+ db_path: str = str(RAGFLY_HOME / "data.db")
47
+
48
+ # NOTA: el cliente NO configura su modelo LLM ni de embeddings ni sus API
49
+ # keys. El modelo de cada paso lo gobierna la habilidad/transición
50
+ # sincronizada del cloud (registro_llm vía sync_catalogo) y las keys vienen
51
+ # de cat_llm_keys sincronizado. Cualquier "elección de modelo" debe vivir en
52
+ # el catálogo, nunca como literal en el cliente.
53
+
54
+ # --- Sync ---
55
+ sync_batch_size: int = 500
56
+ sync_max_reintentos: int = 5
57
+ sync_comprimir: bool = True
58
+
59
+ # --- Flags de pipeline ---
60
+ # Los flags `analizar_habilitado` / `chunkear_habilitado` fueron eliminados
61
+ # del cloud en mig 325 (limpieza de parámetros + parametrización por plan).
62
+ # El cliente asume True para todos los pasos; el control administrativo se
63
+ # hace en `rel_transiciones_estado.activo` del cloud.
64
+ # La vectorización ocurre SIEMPRE en el cloud con su modelo sincronizado
65
+ # (VECTORIZAR_CHUNKS → registro_llm); no hay embeddings local.
66
+
67
+ # --- General ---
68
+ debug: bool = False
69
+
70
+ model_config = {
71
+ "env_prefix": "RAGFLY_",
72
+ "extra": "ignore",
73
+ }
74
+
75
+ @classmethod
76
+ def settings_customise_sources(cls, settings_cls, **kwargs):
77
+ """Carga el env_file de forma dinámica (runtime) para que
78
+ RAGFLY_HOME sea respetado incluso si cambia después del import."""
79
+ from pydantic_settings import DotEnvSettingsSource
80
+ init_settings = kwargs.get("init_settings")
81
+ env_settings = kwargs.get("env_settings")
82
+ return (
83
+ init_settings,
84
+ env_settings,
85
+ DotEnvSettingsSource(settings_cls, env_file=str(_get_home() / "config.env")),
86
+ )
87
+
88
+
89
+ @lru_cache
90
+ def get_config() -> ClienteConfig:
91
+ """Retorna la configuración cacheada del cliente."""
92
+ return ClienteConfig()
93
+
94
+
95
+ def config_existe() -> bool:
96
+ """Verifica si ya se ejecutó el setup (evaluado en runtime)."""
97
+ return (_get_home() / "config.env").exists()
98
+
99
+
100
+ def crear_directorio_home():
101
+ """Crea ~/.ragfly/ si no existe (evaluado en runtime)."""
102
+ _get_home().mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,142 @@
1
+ """
2
+ Gestión del grupo activo del RAGfly Cliente.
3
+
4
+ El cliente sigue el mismo modelo que el dropdown del header web: el grupo
5
+ activo es un override de sesión (no persiste en BD del cloud), pero sí se
6
+ guarda localmente en `~/.ragfly/config.env` para que sobreviva entre
7
+ ejecuciones del cliente.
8
+
9
+ Funciones:
10
+ listar_grupos_disponibles(token) -> list[dict]
11
+ set_grupo_activo(codigo_grupo) -> None
12
+ clear_grupo_activo() -> None
13
+ get_grupo_activo_local() -> str | None
14
+ cambiar_grupo_remoto(codigo_grupo, token) -> dict # valida con backend
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ import httpx
24
+
25
+ from ._http import default_headers
26
+
27
+
28
+ def _config_path() -> Path:
29
+ """Ruta al config.env (resuelta al llamar)."""
30
+ from .config import _get_home
31
+ return _get_home() / "config.env"
32
+
33
+
34
+ # ── Persistencia local en ~/.ragfly/config.env ──────────────────────────────
35
+
36
+
37
+ def get_grupo_activo_local() -> Optional[str]:
38
+ """Lee el grupo activo desde config (cargado por pydantic-settings)."""
39
+ from .config import get_config
40
+ return get_config().codigo_grupo or None
41
+
42
+
43
+ def set_grupo_activo(codigo_grupo: str) -> None:
44
+ """Persiste `RAGFLY_CODIGO_GRUPO=codigo_grupo` en config.env.
45
+
46
+ Si la línea ya existe la reemplaza; si no, la agrega al final.
47
+ Invalida el cache de get_config() para que la próxima lectura sea fresca.
48
+ """
49
+ path = _config_path()
50
+ if not path.exists():
51
+ raise FileNotFoundError(
52
+ f"No existe {path}. Ejecuta `ragfly setup` primero."
53
+ )
54
+
55
+ content = path.read_text()
56
+ pattern = re.compile(r"^RAGFLY_CODIGO_GRUPO=.*$", re.MULTILINE)
57
+ new_line = f"RAGFLY_CODIGO_GRUPO={codigo_grupo}"
58
+
59
+ if pattern.search(content):
60
+ content = pattern.sub(new_line, content)
61
+ else:
62
+ if not content.endswith("\n"):
63
+ content += "\n"
64
+ content += new_line + "\n"
65
+
66
+ path.write_text(content)
67
+
68
+ from .config import get_config
69
+ get_config.cache_clear()
70
+
71
+
72
+ def clear_grupo_activo() -> None:
73
+ """Quita RAGFLY_CODIGO_GRUPO del config.env (vuelve a grupo defecto del usuario)."""
74
+ set_grupo_activo("")
75
+
76
+
77
+ def set_directorio_documentos(directorio: str) -> None:
78
+ """Persiste `RAGFLY_DIRECTORIO_DOCUMENTOS` en config.env (lo usa el pipeline
79
+ para resolver la ruta absoluta de cada archivo al extraer texto).
80
+
81
+ Crea el config.env si no existe (puede ocurrir antes del primer `setup`).
82
+ Invalida el cache de get_config() para que la próxima lectura sea fresca.
83
+ """
84
+ path = _config_path()
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ content = path.read_text() if path.exists() else ""
87
+
88
+ pattern = re.compile(r"^RAGFLY_DIRECTORIO_DOCUMENTOS=.*$", re.MULTILINE)
89
+ new_line = f"RAGFLY_DIRECTORIO_DOCUMENTOS={directorio}"
90
+ if pattern.search(content):
91
+ content = pattern.sub(new_line, content)
92
+ else:
93
+ if content and not content.endswith("\n"):
94
+ content += "\n"
95
+ content += new_line + "\n"
96
+ path.write_text(content)
97
+
98
+ from .config import get_config
99
+ get_config.cache_clear()
100
+
101
+
102
+ # ── Operaciones contra el backend cloud ──────────────────────────────────────
103
+
104
+
105
+ def listar_grupos_disponibles(token: str, cloud_url: str, *, timeout: int = 15) -> list[dict]:
106
+ """Retorna la lista de grupos a los que el usuario tiene acceso.
107
+
108
+ Cada item: `{codigo_grupo, nombre_grupo, alias_grupo}`.
109
+ """
110
+ r = httpx.get(
111
+ f"{cloud_url.rstrip('/')}/auth/me",
112
+ headers=default_headers(token=token),
113
+ timeout=timeout,
114
+ )
115
+ r.raise_for_status()
116
+ data = r.json()
117
+ return [
118
+ {
119
+ "codigo_grupo": g.get("codigo_grupo"),
120
+ "nombre_grupo": g.get("nombre_grupo"),
121
+ "alias_grupo": g.get("alias_grupo"),
122
+ }
123
+ for g in data.get("grupos", [])
124
+ ]
125
+
126
+
127
+ def cambiar_grupo_remoto(
128
+ codigo_grupo: str, token: str, cloud_url: str, *, timeout: int = 15
129
+ ) -> dict:
130
+ """Llama POST /auth/cambiar-grupo para validar pertenencia y obtener contexto.
131
+
132
+ Retorna el `UsuarioContexto` actualizado del backend.
133
+ Lanza httpx.HTTPStatusError si el grupo no existe o el usuario no tiene acceso.
134
+ """
135
+ r = httpx.post(
136
+ f"{cloud_url.rstrip('/')}/auth/cambiar-grupo",
137
+ headers=default_headers(token=token, content_type=True),
138
+ json={"codigo_grupo": codigo_grupo},
139
+ timeout=timeout,
140
+ )
141
+ r.raise_for_status()
142
+ return r.json()
@@ -0,0 +1,70 @@
1
+ """
2
+ Almacén persistente del JWT y email del usuario en el keyring del SO.
3
+
4
+ - macOS: Keychain (Acceso a Llaveros)
5
+ - Windows: Credential Manager
6
+ - Linux: libsecret / Secret Service
7
+
8
+ Doc 10 § 6 (SSO γ): el JWT se persiste fuera de SQLite y fuera del filesystem
9
+ plano del usuario. Vive en el keyring del SO, accesible solo por la app y el
10
+ usuario logueado en la sesión del SO.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import keyring
16
+ from keyring.errors import KeyringError
17
+
18
+ SERVICE = "ragfly-ragflydesktop"
19
+ KEY_TOKEN = "jwt"
20
+ KEY_EMAIL = "email"
21
+
22
+ # Cache en RAM por sesión del proceso. Evita re-leer el Keychain en cada
23
+ # llamada (cada lectura dispara un prompt del llavero cuando la app está
24
+ # firmada ad-hoc, como en builds de desarrollo). Se lee una sola vez y se
25
+ # refresca al guardar/borrar. Sentinela _NO_LEIDO distingue "no leído aún"
26
+ # de "leído y vacío".
27
+ _NO_LEIDO = object()
28
+ _cache_token: object | str | None = _NO_LEIDO
29
+ _cache_email: object | str | None = _NO_LEIDO
30
+
31
+
32
+ def guardar(token: str, email: str) -> None:
33
+ global _cache_token, _cache_email
34
+ keyring.set_password(SERVICE, KEY_TOKEN, token)
35
+ keyring.set_password(SERVICE, KEY_EMAIL, email)
36
+ _cache_token = token
37
+ _cache_email = email
38
+
39
+
40
+ def leer_token() -> str | None:
41
+ global _cache_token
42
+ if _cache_token is not _NO_LEIDO:
43
+ return _cache_token # type: ignore[return-value]
44
+ try:
45
+ _cache_token = keyring.get_password(SERVICE, KEY_TOKEN)
46
+ except KeyringError:
47
+ _cache_token = None
48
+ return _cache_token # type: ignore[return-value]
49
+
50
+
51
+ def leer_email() -> str | None:
52
+ global _cache_email
53
+ if _cache_email is not _NO_LEIDO:
54
+ return _cache_email # type: ignore[return-value]
55
+ try:
56
+ _cache_email = keyring.get_password(SERVICE, KEY_EMAIL)
57
+ except KeyringError:
58
+ _cache_email = None
59
+ return _cache_email # type: ignore[return-value]
60
+
61
+
62
+ def borrar() -> None:
63
+ global _cache_token, _cache_email
64
+ for k in (KEY_TOKEN, KEY_EMAIL):
65
+ try:
66
+ keyring.delete_password(SERVICE, k)
67
+ except KeyringError:
68
+ pass
69
+ _cache_token = None
70
+ _cache_email = None
@@ -0,0 +1,12 @@
1
+ """
2
+ Capa OOP del cliente — clases reutilizables para reducir duplicación.
3
+
4
+ Componentes:
5
+ - CloudHttpClient — wrapper HTTP unificado contra la API cloud (GET/POST/PUT/DELETE)
6
+ - CliCommand — base class para comandos CLI con manejo uniforme de errores
7
+ """
8
+
9
+ from ragfly_cli.oop.http_client import CloudHttpClient
10
+ from ragfly_cli.oop.cli_command import CliCommand
11
+
12
+ __all__ = ["CloudHttpClient", "CliCommand"]
@@ -0,0 +1,86 @@
1
+ """
2
+ CliCommand — base class para comandos CLI con manejo uniforme de errores.
3
+
4
+ Encapsula el patrón repetido en `cli.py`:
5
+ try:
6
+ data = operacion()
7
+ except CloudError as e:
8
+ err_console.print(f"[red]✗ {e}[/red]")
9
+ raise SystemExit(e.exit_code)
10
+
11
+ Ejemplo:
12
+ from ragfly_cli.oop import CliCommand
13
+
14
+ class MiComando(CliCommand):
15
+ def ejecutar(self, x):
16
+ data = self.protegido(lambda: cloud_get(f"/algo/{x}"))
17
+ self.exito(f"Listo: {data}")
18
+
19
+ MiComando().ejecutar("foo")
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+ from typing import Any, Callable, NoReturn
26
+
27
+ from rich.console import Console
28
+
29
+ from ragfly_cli.cloud_commands import CloudError
30
+
31
+
32
+ class CliCommand:
33
+ """Base class para comandos CLI con consoles + helpers de output."""
34
+
35
+ def __init__(self):
36
+ self.console = Console()
37
+ self.err_console = Console(stderr=True)
38
+
39
+ # ── Output helpers ────────────────────────────────────────────────────────
40
+
41
+ def exito(self, mensaje: str) -> None:
42
+ self.console.print(f"[green]✓ {mensaje}[/green]")
43
+
44
+ def error(self, mensaje: str) -> None:
45
+ self.err_console.print(f"[red]✗ {mensaje}[/red]")
46
+
47
+ def info(self, mensaje: str) -> None:
48
+ self.console.print(f"[dim]{mensaje}[/dim]")
49
+
50
+ def aviso(self, mensaje: str) -> None:
51
+ self.console.print(f"[yellow]⚠ {mensaje}[/yellow]")
52
+
53
+ def linea(self) -> None:
54
+ self.console.print()
55
+
56
+ # ── Ejecución protegida ───────────────────────────────────────────────────
57
+
58
+ def protegido(
59
+ self,
60
+ fn: Callable[..., Any],
61
+ *args,
62
+ on_unexpected_exit_code: int = 2,
63
+ **kwargs,
64
+ ) -> Any:
65
+ """
66
+ Ejecuta `fn(*args, **kwargs)` capturando CloudError y excepciones imprevistas;
67
+ imprime el error y termina con SystemExit usando el exit_code apropiado.
68
+ """
69
+ try:
70
+ return fn(*args, **kwargs)
71
+ except CloudError as e:
72
+ self.error(str(e))
73
+ raise SystemExit(e.exit_code)
74
+ except SystemExit:
75
+ raise
76
+ except KeyboardInterrupt:
77
+ self.aviso("Interrumpido.")
78
+ raise SystemExit(130)
79
+ except Exception as e:
80
+ self.error(f"Error inesperado: {e}")
81
+ raise SystemExit(on_unexpected_exit_code)
82
+
83
+ def salir(self, mensaje: str, exit_code: int = 1) -> NoReturn:
84
+ """Imprime el error y sale con el código dado."""
85
+ self.error(mensaje)
86
+ raise SystemExit(exit_code)