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.
- ragfly_cli/__init__.py +1 -0
- ragfly_cli/__main__.py +5 -0
- ragfly_cli/_http.py +67 -0
- ragfly_cli/cli.py +918 -0
- ragfly_cli/cloud_commands.py +201 -0
- ragfly_cli/config.py +102 -0
- ragfly_cli/grupo_activo.py +142 -0
- ragfly_cli/keyring_store.py +70 -0
- ragfly_cli/oop/__init__.py +12 -0
- ragfly_cli/oop/cli_command.py +86 -0
- ragfly_cli/oop/http_client.py +106 -0
- ragfly_cli/version_check.py +96 -0
- ragfly_cli-1.16.0.dist-info/METADATA +73 -0
- ragfly_cli-1.16.0.dist-info/RECORD +17 -0
- ragfly_cli-1.16.0.dist-info/WHEEL +5 -0
- ragfly_cli-1.16.0.dist-info/entry_points.txt +2 -0
- ragfly_cli-1.16.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|