lockthard-cli 0.1.0__tar.gz

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,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockthard-cli
3
+ Version: 0.1.0
4
+ Summary: Super efficient CLI for LOCKTHARD AI tools directory and agents
5
+ Author-email: Lockthard Team <info@lockthard.es>
6
+ License: MIT
7
+ Project-URL: Homepage, https://lockthard.es
8
+ Keywords: cli,ai-tools,agents,mcp,lockthard
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: click>=8.1.0
20
+ Requires-Dist: requests>=2.28.0
21
+ Requires-Dist: rich>=13.0.0
22
+
23
+ # LOCKTHARD CLI (`lockthard`)
24
+
25
+ A super efficient, scriptable command-line interface and AI agent client for **LOCKTHARD** (https://lockthard.es).
26
+
27
+ Allows both human developers and autonomous AI agents (Claude Code, Antigravity, Aider, Cursor, etc.) to query, search, and inspect 61+ tablets and 1200+ AI tools with minimum token usage and zero prompt overhead.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install lockthard-cli
33
+ ```
34
+
35
+ Or run without installing:
36
+
37
+ ```bash
38
+ pipx run lockthard-cli --help
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ### 1. Authentication
44
+ Get your API key from [lockthard.es](https://lockthard.es).
45
+
46
+ - **For AI Agents & CI/CD:**
47
+ ```bash
48
+ export LOCKTHARD_API_KEY="lk_your_token_here"
49
+ ```
50
+ - **Interactive Browser Login (Humans):**
51
+ ```bash
52
+ lockthard login
53
+ ```
54
+ - **Manual Login:**
55
+ ```bash
56
+ lockthard login --token "lk_your_token_here"
57
+ ```
58
+
59
+ ### 2. Commands
60
+
61
+ ```bash
62
+ # Ping service
63
+ lockthard ping
64
+
65
+ # Search tools (returns concise ASCII table)
66
+ lockthard search "ocr"
67
+
68
+ # Search tools in raw JSON (for agents / jq)
69
+ lockthard search "ocr" --json
70
+
71
+ # List all tablets
72
+ lockthard tablets list
73
+
74
+ # Inspect a specific tablet
75
+ lockthard tablets get ocr
76
+
77
+ # Inspect tool details
78
+ lockthard tools get "Chandra OCR"
79
+
80
+ # Display CLI agent guide
81
+ lockthard guide
82
+ ```
83
+
84
+ ## Options
85
+
86
+ - `--json` / `-j`: Output raw JSON without Rich styling.
87
+ - `--url` / `-u`: Override base server URL (default: `https://lockthard.es`).
88
+ - `--token` / `-t`: Override API token.
89
+
90
+ ## License
91
+
92
+ MIT License
@@ -0,0 +1,70 @@
1
+ # LOCKTHARD CLI (`lockthard`)
2
+
3
+ A super efficient, scriptable command-line interface and AI agent client for **LOCKTHARD** (https://lockthard.es).
4
+
5
+ Allows both human developers and autonomous AI agents (Claude Code, Antigravity, Aider, Cursor, etc.) to query, search, and inspect 61+ tablets and 1200+ AI tools with minimum token usage and zero prompt overhead.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install lockthard-cli
11
+ ```
12
+
13
+ Or run without installing:
14
+
15
+ ```bash
16
+ pipx run lockthard-cli --help
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### 1. Authentication
22
+ Get your API key from [lockthard.es](https://lockthard.es).
23
+
24
+ - **For AI Agents & CI/CD:**
25
+ ```bash
26
+ export LOCKTHARD_API_KEY="lk_your_token_here"
27
+ ```
28
+ - **Interactive Browser Login (Humans):**
29
+ ```bash
30
+ lockthard login
31
+ ```
32
+ - **Manual Login:**
33
+ ```bash
34
+ lockthard login --token "lk_your_token_here"
35
+ ```
36
+
37
+ ### 2. Commands
38
+
39
+ ```bash
40
+ # Ping service
41
+ lockthard ping
42
+
43
+ # Search tools (returns concise ASCII table)
44
+ lockthard search "ocr"
45
+
46
+ # Search tools in raw JSON (for agents / jq)
47
+ lockthard search "ocr" --json
48
+
49
+ # List all tablets
50
+ lockthard tablets list
51
+
52
+ # Inspect a specific tablet
53
+ lockthard tablets get ocr
54
+
55
+ # Inspect tool details
56
+ lockthard tools get "Chandra OCR"
57
+
58
+ # Display CLI agent guide
59
+ lockthard guide
60
+ ```
61
+
62
+ ## Options
63
+
64
+ - `--json` / `-j`: Output raw JSON without Rich styling.
65
+ - `--url` / `-u`: Override base server URL (default: `https://lockthard.es`).
66
+ - `--token` / `-t`: Override API token.
67
+
68
+ ## License
69
+
70
+ MIT License
@@ -0,0 +1,5 @@
1
+ """
2
+ LOCKTHARD CLI - Command Line Interface and Agent Client for LOCKTHARD.
3
+ """
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,159 @@
1
+ import http.server
2
+ import socket
3
+ import socketserver
4
+ import urllib.parse
5
+ import webbrowser
6
+ from typing import Tuple, Optional, Dict, Any
7
+ from .config import save_credentials, clear_credentials
8
+
9
+
10
+ class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
11
+ """Temporary HTTP handler to capture the token callback from browser authentication."""
12
+
13
+ received_token: Optional[str] = None
14
+ received_email: Optional[str] = None
15
+ error_message: Optional[str] = None
16
+
17
+ def log_message(self, format, *args):
18
+ # Silence default HTTP server logging
19
+ pass
20
+
21
+ def do_GET(self):
22
+ parsed = urllib.parse.urlparse(self.path)
23
+ if parsed.path in ("/callback", "/"):
24
+ query = urllib.parse.parse_qs(parsed.query)
25
+ token = query.get("token", [None])[0]
26
+ email = query.get("email", [None])[0]
27
+ error = query.get("error", [None])[0]
28
+
29
+ if error:
30
+ OAuthCallbackHandler.error_message = error
31
+ self._send_response_html(
32
+ status=400,
33
+ title="Error de Autenticación",
34
+ body=f"<p style='color: red;'>Error durante el inicio de sesión: {error}</p>"
35
+ )
36
+ elif token:
37
+ OAuthCallbackHandler.received_token = token
38
+ OAuthCallbackHandler.received_email = email
39
+ self._send_response_html(
40
+ status=200,
41
+ title="¡Autenticación Exitosa!",
42
+ body="""
43
+ <h2 style='color: #10B981;'>¡Autenticación Exitosa!</h2>
44
+ <p>La CLI de <strong>LOCKTHARD</strong> ha sido conectada correctamente a tu cuenta.</p>
45
+ <p>Ya puedes cerrar esta ventana y regresar a tu terminal.</p>
46
+ """
47
+ )
48
+ else:
49
+ self._send_response_html(
50
+ status=400,
51
+ title="Falta Token",
52
+ body="<p style='color: red;'>No se recibió ningún token de autenticación.</p>"
53
+ )
54
+ else:
55
+ self.send_error(404, "Not Found")
56
+
57
+ def _send_response_html(self, status: int, title: str, body: str):
58
+ self.send_response(status)
59
+ self.send_header("Content-Type", "text/html; charset=utf-8")
60
+ self.end_headers()
61
+
62
+ html = f"""<!DOCTYPE html>
63
+ <html>
64
+ <head>
65
+ <meta charset="utf-8">
66
+ <title>{title}</title>
67
+ <style>
68
+ body {{
69
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
70
+ background: #0d1117;
71
+ color: #c9d1d9;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ height: 100vh;
76
+ margin: 0;
77
+ }}
78
+ .card {{
79
+ background: #161b22;
80
+ border: 1px solid #30363d;
81
+ border-radius: 12px;
82
+ padding: 32px;
83
+ max-width: 420px;
84
+ text-align: center;
85
+ box-shadow: 0 10px 25px rgba(0,0,0,0.5);
86
+ }}
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <div class="card">
91
+ {body}
92
+ </div>
93
+ </body>
94
+ </html>
95
+ """
96
+ self.wfile.write(html.encode("utf-8"))
97
+
98
+
99
+ def find_free_port(start_port: int = 8989) -> int:
100
+ """Find an available port starting from start_port."""
101
+ for port in range(start_port, start_port + 20):
102
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
103
+ try:
104
+ s.bind(("127.0.0.1", port))
105
+ return port
106
+ except OSError:
107
+ continue
108
+ return start_port
109
+
110
+
111
+ def perform_browser_login(api_url: str, timeout_seconds: int = 120) -> Tuple[bool, str]:
112
+ """
113
+ Launch local server, open browser to lockthard.es/cli-auth, and capture token.
114
+ """
115
+ port = find_free_port(8989)
116
+ callback_url = f"http://127.0.0.1:{port}/callback"
117
+ auth_url = f"{api_url.rstrip('/')}/cli-auth?callback={urllib.parse.quote(callback_url)}"
118
+
119
+ OAuthCallbackHandler.received_token = None
120
+ OAuthCallbackHandler.received_email = None
121
+ OAuthCallbackHandler.error_message = None
122
+
123
+ class ReusableTCPServer(socketserver.TCPServer):
124
+ allow_reuse_address = True
125
+
126
+ try:
127
+ httpd = ReusableTCPServer(("127.0.0.1", port), OAuthCallbackHandler)
128
+ httpd.timeout = 1.0
129
+ except Exception as e:
130
+ return False, f"No se pudo iniciar el servidor local en el puerto {port}: {e}"
131
+
132
+ print(f"Abriendo el navegador para autenticar en LOCKTHARD...")
133
+ print(f"URL: {auth_url}\n")
134
+ try:
135
+ webbrowser.open(auth_url)
136
+ except Exception:
137
+ print("No se pudo abrir el navegador automáticamente. Por favor abre la URL manualmente.")
138
+
139
+ import time
140
+ start_time = time.time()
141
+
142
+ while time.time() - start_time < timeout_seconds:
143
+ httpd.handle_request()
144
+ if OAuthCallbackHandler.received_token:
145
+ token = OAuthCallbackHandler.received_token
146
+ email = OAuthCallbackHandler.received_email
147
+ httpd.server_close()
148
+
149
+ # Save credentials
150
+ save_credentials(api_key=token, email=email, api_url=api_url)
151
+ user_str = f" como {email}" if email else ""
152
+ return True, f"¡Autenticado correctamente{user_str}!"
153
+
154
+ if OAuthCallbackHandler.error_message:
155
+ httpd.server_close()
156
+ return False, f"Error devuelto por la autenticación: {OAuthCallbackHandler.error_message}"
157
+
158
+ httpd.server_close()
159
+ return False, "Tiempo de espera agotado (timeout) esperando la autorización en el navegador."
@@ -0,0 +1,196 @@
1
+ import sys
2
+ from typing import Optional
3
+ import click
4
+
5
+ from . import __version__
6
+ from .config import resolve_token, resolve_api_url, save_credentials, clear_credentials
7
+ from .auth import perform_browser_login
8
+ from .client import LockthardClient, LockthardError
9
+ from .formatters import (
10
+ output_data,
11
+ print_ping_result,
12
+ print_tablets_list,
13
+ print_search_results,
14
+ print_tablet_content,
15
+ print_tool_details,
16
+ print_cli_tools,
17
+ print_agent_guide,
18
+ print_agent_skill
19
+ )
20
+
21
+
22
+ def _handle_error(err: Exception):
23
+ """Print clean error message without traceback."""
24
+ click.secho(f"Error: {err}", fg="red", err=True)
25
+ sys.exit(1)
26
+
27
+
28
+ @click.group(invoke_without_command=False)
29
+ @click.option("--url", "-u", default=None, help="Base URL del servidor LOCKTHARD (default: https://lockthard.es).")
30
+ @click.option("--token", "-t", default=None, help="API Key / Token de autenticación (lk_...).")
31
+ @click.option("--json", "-j", "as_json", is_flag=True, help="Salida en JSON bruto (ideal para scripts y agentes).")
32
+ @click.version_option(version=__version__, prog_name="lockthard")
33
+ @click.pass_context
34
+ def cli(ctx: click.Context, url: Optional[str], token: Optional[str], as_json: bool):
35
+ """LOCKTHARD CLI - Cliente super eficiente de herramientas IA para humanos y agentes."""
36
+ resolved_url = resolve_api_url(url)
37
+ resolved_token = resolve_token(token)
38
+
39
+ ctx.ensure_object(dict)
40
+ ctx.obj["url"] = resolved_url
41
+ ctx.obj["token"] = resolved_token
42
+ ctx.obj["as_json"] = as_json
43
+ ctx.obj["client"] = LockthardClient(base_url=resolved_url, token=resolved_token)
44
+
45
+
46
+ @cli.command()
47
+ @click.pass_context
48
+ def ping(ctx: click.Context):
49
+ """Verifica la conectividad con el servidor de LOCKTHARD y el estado del token."""
50
+ client: LockthardClient = ctx.obj["client"]
51
+ try:
52
+ res = client.ping()
53
+ print_ping_result(res, as_json=ctx.obj["as_json"])
54
+ except LockthardError as e:
55
+ _handle_error(e)
56
+
57
+
58
+ @cli.command()
59
+ @click.option("--token", "-t", help="API Key directa (lk_...) para guardar sin interacción.")
60
+ @click.option("--manual", "-m", is_flag=True, help="Modo interactivo en consola (sin abrir navegador).")
61
+ @click.pass_context
62
+ def login(ctx: click.Context, token: Optional[str], manual: bool):
63
+ """Inicia sesión en LOCKTHARD (Browser OAuth, Token o Interactivo)."""
64
+ api_url = ctx.obj["url"]
65
+
66
+ # 1. Direct token via flag
67
+ if token and token.strip():
68
+ save_credentials(api_key=token.strip(), api_url=api_url)
69
+ click.secho("✓ API Key guardada correctamente en ~/.lockthard/credentials.json", fg="green")
70
+ return
71
+
72
+ # 2. Manual prompt via flag
73
+ if manual:
74
+ token_input = click.prompt("Pega tu API Key de LOCKTHARD (lk_...)", hide_input=True)
75
+ if not token_input or not token_input.strip():
76
+ _handle_error(LockthardError("Token vacío."))
77
+ save_credentials(api_key=token_input.strip(), api_url=api_url)
78
+ click.secho("✓ API Key guardada correctamente en ~/.lockthard/credentials.json", fg="green")
79
+ return
80
+
81
+ # 3. Interactive Browser Login
82
+ click.echo(f"Iniciando flujo de autenticación para {api_url}...")
83
+ success, msg = perform_browser_login(api_url)
84
+ if success:
85
+ click.secho(f"✓ {msg}", fg="green")
86
+ else:
87
+ click.secho(f"✗ {msg}", fg="yellow")
88
+ click.echo("Puedes autenticarte manualmente con: lockthard login --token lk_tu_token")
89
+
90
+
91
+ @cli.command()
92
+ def logout():
93
+ """Cierra la sesión y elimina las credenciales locales."""
94
+ if clear_credentials():
95
+ click.secho("✓ Sesión cerrada y credenciales eliminadas (~/.lockthard/credentials.json).", fg="green")
96
+ else:
97
+ click.echo("No había ninguna sesión activa guardada.")
98
+
99
+
100
+ @cli.command()
101
+ @click.argument("query")
102
+ @click.option("--tablet", "-t", help="Filtrar por ID de tablet específica.")
103
+ @click.option("--limit", "-l", default=20, type=int, help="Límite de resultados (default: 20).")
104
+ @click.pass_context
105
+ def search(ctx: click.Context, query: str, tablet: Optional[str], limit: int):
106
+ """Busca herramientas por palabra clave en LOCKTHARD."""
107
+ client: LockthardClient = ctx.obj["client"]
108
+ try:
109
+ results = client.search_tools(query=query, tablet_id=tablet, limit=limit)
110
+ print_search_results(query, results, as_json=ctx.obj["as_json"])
111
+ except LockthardError as e:
112
+ _handle_error(e)
113
+
114
+
115
+ @cli.group()
116
+ def tablets():
117
+ """Comandos para explorar las tablets de LOCKTHARD."""
118
+ pass
119
+
120
+
121
+ @tablets.command(name="list")
122
+ @click.option("--query", "-q", help="Filtrar tablets por título o categoría.")
123
+ @click.pass_context
124
+ def list_tablets_cmd(ctx: click.Context, query: Optional[str]):
125
+ """Lista los 61+ tablets y categorías disponibles."""
126
+ client: LockthardClient = ctx.obj["client"]
127
+ try:
128
+ res = client.list_tablets(query=query)
129
+ print_tablets_list(res, as_json=ctx.obj["as_json"])
130
+ except LockthardError as e:
131
+ _handle_error(e)
132
+
133
+
134
+ @tablets.command(name="get")
135
+ @click.argument("tablet_id")
136
+ @click.option("--limit", "-l", type=int, help="Límite de herramientas a devolver.")
137
+ @click.pass_context
138
+ def get_tablet_cmd(ctx: click.Context, tablet_id: str, limit: Optional[int]):
139
+ """Obtiene el contenido completo de un tablet por su ID (ej. ocr, open-source)."""
140
+ client: LockthardClient = ctx.obj["client"]
141
+ try:
142
+ res = client.get_tablet(tablet_id=tablet_id, limit=limit)
143
+ print_tablet_content(res, as_json=ctx.obj["as_json"])
144
+ except LockthardError as e:
145
+ _handle_error(e)
146
+
147
+
148
+ @cli.group()
149
+ def tools():
150
+ """Comandos para inspeccionar herramientas individuales."""
151
+ pass
152
+
153
+
154
+ @tools.command(name="get")
155
+ @click.argument("tool_id")
156
+ @click.pass_context
157
+ def get_tool_cmd(ctx: click.Context, tool_id: str):
158
+ """Obtiene los detalles y comandos recomendados de una herramienta específica."""
159
+ client: LockthardClient = ctx.obj["client"]
160
+ try:
161
+ res = client.get_tool_details(tool_id=tool_id)
162
+ print_tool_details(res, as_json=ctx.obj["as_json"])
163
+ except LockthardError as e:
164
+ _handle_error(e)
165
+
166
+
167
+ @cli.command(name="cli-tools")
168
+ @click.pass_context
169
+ def cli_tools_cmd(ctx: click.Context):
170
+ """Lista las herramientas especializadas de línea de comandos en LOCKTHARD."""
171
+ client: LockthardClient = ctx.obj["client"]
172
+ try:
173
+ res = client.get_cli_tools()
174
+ print_cli_tools(res, as_json=ctx.obj["as_json"])
175
+ except LockthardError as e:
176
+ _handle_error(e)
177
+
178
+
179
+ @cli.command()
180
+ def guide():
181
+ """Muestra la guía rápida de la CLI optimizada para Agentes de IA."""
182
+ print_agent_guide()
183
+
184
+
185
+ @cli.command()
186
+ def skill():
187
+ """Genera la especificación SKILL.md completa para que un Agente de IA la importe."""
188
+ print_agent_skill()
189
+
190
+
191
+ def main():
192
+ cli(obj={})
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()
@@ -0,0 +1,141 @@
1
+ import json
2
+ from typing import Any, Dict, List, Optional
3
+ import requests
4
+
5
+
6
+ class LockthardError(Exception):
7
+ """Base exception for LOCKTHARD CLI client errors."""
8
+ pass
9
+
10
+
11
+ class LockthardClient:
12
+ """Pure data layer for communicating with the LOCKTHARD API backend."""
13
+
14
+ def __init__(self, base_url: str = "https://lockthard.es", token: Optional[str] = None, timeout: int = 15):
15
+ self.base_url = base_url.rstrip("/")
16
+ self.token = token.strip() if token else None
17
+ self.timeout = timeout
18
+ self._session = requests.Session()
19
+ self._session.headers.update({"Accept": "application/json"})
20
+ if self.token:
21
+ self._session.headers.update({"Authorization": f"Bearer {self.token}"})
22
+ self._jsonrpc_id = 0
23
+
24
+ def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
25
+ try:
26
+ url = f"{self.base_url}{path}"
27
+ resp = self._session.get(url, params=params, timeout=self.timeout)
28
+ if resp.status_code == 401:
29
+ raise LockthardError("No autorizado: Token inválido o no proporcionado.")
30
+ resp.raise_for_status()
31
+ if not resp.content:
32
+ return {}
33
+ return resp.json()
34
+ except requests.ConnectionError:
35
+ raise LockthardError(f"No se pudo conectar con el servidor en {self.base_url}")
36
+ except requests.Timeout:
37
+ raise LockthardError(f"Tiempo de espera agotado ({self.timeout}s) conectando a {self.base_url}")
38
+ except requests.HTTPError as e:
39
+ raise LockthardError(f"Error HTTP del servidor: {e}")
40
+ except ValueError:
41
+ raise LockthardError("Respuesta de servidor no válida (JSON inválido).")
42
+
43
+ def _mcp_call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Any:
44
+ """Call LOCKTHARD JSON-RPC MCP endpoint at /api/mcp."""
45
+ self._jsonrpc_id += 1
46
+ payload = {
47
+ "jsonrpc": "2.0",
48
+ "id": self._jsonrpc_id,
49
+ "method": method,
50
+ "params": params or {}
51
+ }
52
+ try:
53
+ url = f"{self.base_url}/api/mcp"
54
+ resp = self._session.post(url, json=payload, timeout=self.timeout)
55
+ if resp.status_code == 401:
56
+ raise LockthardError("No autorizado: Token inválido. Inicia sesión con 'lockthard login'.")
57
+ resp.raise_for_status()
58
+ data = resp.json()
59
+ if "error" in data:
60
+ err = data["error"]
61
+ raise LockthardError(f"Error MCP ({err.get('code', 'unknown')}): {err.get('message', '')}")
62
+
63
+ result = data.get("result", {})
64
+ # Decode MCP text content if wrapped
65
+ if isinstance(result, dict) and "content" in result and isinstance(result["content"], list):
66
+ for item in result["content"]:
67
+ if isinstance(item, dict) and item.get("type") == "text":
68
+ text_val = item.get("text", "")
69
+ try:
70
+ return json.loads(text_val)
71
+ except (ValueError, TypeError):
72
+ return text_val
73
+ return result
74
+ except requests.ConnectionError:
75
+ raise LockthardError(f"No se pudo conectar con el servidor en {self.base_url}")
76
+ except requests.Timeout:
77
+ raise LockthardError(f"Tiempo de espera agotado ({self.timeout}s) conectando a {self.base_url}")
78
+ except requests.HTTPError as e:
79
+ raise LockthardError(f"Error HTTP del servidor: {e}")
80
+ except ValueError:
81
+ raise LockthardError("Respuesta de servidor no válida (JSON inválido).")
82
+
83
+ def ping(self) -> Dict[str, Any]:
84
+ """Check API connectivity and auth status."""
85
+ if self.token:
86
+ try:
87
+ data = self._get("/api/mcp/ping")
88
+ return {"status": "ok", "authenticated": True, "details": data}
89
+ except LockthardError as e:
90
+ if "No autorizado" in str(e):
91
+ return {"status": "unauthorized", "authenticated": False, "error": str(e)}
92
+ raise
93
+ data = self._get("/api/health")
94
+ return {"status": "ok", "authenticated": False, "health": data}
95
+
96
+ def list_tablets(self, query: Optional[str] = None) -> List[Dict[str, Any]]:
97
+ """List all available tablets with counts and summaries."""
98
+ res = self._mcp_call("tools/call", {
99
+ "name": "list_tablets",
100
+ "arguments": {"query": query} if query else {}
101
+ })
102
+ if isinstance(res, list):
103
+ return res
104
+ if isinstance(res, dict) and "tablets" in res:
105
+ return res["tablets"]
106
+ return []
107
+
108
+ def get_tablet(self, tablet_id: str, limit: Optional[int] = None) -> Dict[str, Any]:
109
+ """Get full details and items of a specific tablet."""
110
+ args: Dict[str, Any] = {"tablet_id": tablet_id}
111
+ if limit is not None:
112
+ args["limit"] = limit
113
+ res = self._mcp_call("tools/call", {
114
+ "name": "get_tablet",
115
+ "arguments": args
116
+ })
117
+ return res if isinstance(res, dict) else {"items": res}
118
+
119
+ def search_tools(self, query: str, tablet_id: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
120
+ """Search across all tools in LOCKTHARD directory."""
121
+ args: Dict[str, Any] = {"query": query, "limit": limit}
122
+ if tablet_id:
123
+ args["tablet_id"] = tablet_id
124
+ res = self._mcp_call("tools/call", {
125
+ "name": "search_tools",
126
+ "arguments": args
127
+ })
128
+ return res if isinstance(res, list) else []
129
+
130
+ def get_tool_details(self, tool_id: str) -> Dict[str, Any]:
131
+ """Get full details of a specific tool by ID or name."""
132
+ res = self._mcp_call("tools/call", {
133
+ "name": "get_tool_details",
134
+ "arguments": {"tool_id": tool_id}
135
+ })
136
+ return res if isinstance(res, dict) else {}
137
+
138
+ def get_cli_tools(self) -> List[Dict[str, Any]]:
139
+ """Fetch all CLI specific tools from LOCKTHARD."""
140
+ res = self._get("/api/cli-tools")
141
+ return res if isinstance(res, list) else []
@@ -0,0 +1,114 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Dict, Any, Optional
6
+
7
+ DEFAULT_API_URL = "https://lockthard.es"
8
+ CONFIG_DIR_NAME = ".lockthard"
9
+ CREDENTIALS_FILE_NAME = "credentials.json"
10
+
11
+
12
+ def get_config_dir() -> Path:
13
+ """Return the configuration directory path (~/.lockthard)."""
14
+ home = Path.home()
15
+ config_dir = home / CONFIG_DIR_NAME
16
+ config_dir.mkdir(parents=True, exist_ok=True)
17
+ return config_dir
18
+
19
+
20
+ def get_credentials_path() -> Path:
21
+ """Return the credentials file path (~/.lockthard/credentials.json)."""
22
+ return get_config_dir() / CREDENTIALS_FILE_NAME
23
+
24
+
25
+ def load_credentials() -> Dict[str, Any]:
26
+ """Load credentials from ~/.lockthard/credentials.json if exists."""
27
+ path = get_credentials_path()
28
+ if not path.exists():
29
+ return {}
30
+ try:
31
+ data = json.loads(path.read_text(encoding="utf-8"))
32
+ return data if isinstance(data, dict) else {}
33
+ except Exception:
34
+ return {}
35
+
36
+
37
+ def save_credentials(api_key: str, email: Optional[str] = None, api_url: Optional[str] = None) -> Path:
38
+ """Save credentials securely with restricted permissions (0600 on POSIX)."""
39
+ path = get_credentials_path()
40
+ data = load_credentials()
41
+ data["api_key"] = api_key.strip()
42
+ if email:
43
+ data["email"] = email
44
+ if api_url:
45
+ data["api_url"] = api_url.rstrip("/")
46
+
47
+ # Write json file
48
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
49
+
50
+ # Set 0600 permissions on POSIX
51
+ if os.name != "nt":
52
+ try:
53
+ os.chmod(path, 0o600)
54
+ except OSError:
55
+ pass
56
+
57
+ return path
58
+
59
+
60
+ def clear_credentials() -> bool:
61
+ """Remove the credentials file on logout."""
62
+ path = get_credentials_path()
63
+ if path.exists():
64
+ try:
65
+ path.unlink()
66
+ return True
67
+ except OSError:
68
+ return False
69
+ return False
70
+
71
+
72
+ def resolve_token(token_flag: Optional[str] = None) -> Optional[str]:
73
+ """
74
+ Resolve API key token in precedence order:
75
+ 1. Direct CLI argument / flag (--token)
76
+ 2. Environment variable LOCKTHARD_API_KEY
77
+ 3. Saved credentials (~/.lockthard/credentials.json)
78
+ """
79
+ if token_flag and token_flag.strip():
80
+ return token_flag.strip()
81
+
82
+ env_token = os.environ.get("LOCKTHARD_API_KEY")
83
+ if env_token and env_token.strip():
84
+ return env_token.strip()
85
+
86
+ creds = load_credentials()
87
+ saved_token = creds.get("api_key")
88
+ if saved_token and str(saved_token).strip():
89
+ return str(saved_token).strip()
90
+
91
+ return None
92
+
93
+
94
+ def resolve_api_url(url_flag: Optional[str] = None) -> str:
95
+ """
96
+ Resolve API Base URL in precedence order:
97
+ 1. Direct CLI argument / flag (--url)
98
+ 2. Environment variable LOCKTHARD_API_URL
99
+ 3. Saved credentials api_url
100
+ 4. Default (https://lockthard.es)
101
+ """
102
+ if url_flag and url_flag.strip():
103
+ return url_flag.strip().rstrip("/")
104
+
105
+ env_url = os.environ.get("LOCKTHARD_API_URL")
106
+ if env_url and env_url.strip():
107
+ return env_url.strip().rstrip("/")
108
+
109
+ creds = load_credentials()
110
+ saved_url = creds.get("api_url")
111
+ if saved_url and str(saved_url).strip():
112
+ return str(saved_url).strip().rstrip("/")
113
+
114
+ return DEFAULT_API_URL
@@ -0,0 +1,269 @@
1
+ import json
2
+ import sys
3
+ from typing import Any, List, Dict, Optional
4
+
5
+ try:
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.panel import Panel
9
+ from rich.markdown import Markdown
10
+ from rich.text import Text
11
+ HAS_RICH = True
12
+ except ImportError:
13
+ HAS_RICH = False
14
+
15
+ console = Console() if HAS_RICH else None
16
+
17
+
18
+ def output_data(data: Any, as_json: bool = False):
19
+ """Output raw JSON if requested or default printer."""
20
+ if as_json:
21
+ print(json.dumps(data, indent=2, default=str, ensure_ascii=False))
22
+ elif HAS_RICH and console:
23
+ if isinstance(data, (dict, list)):
24
+ console.print_json(json.dumps(data, default=str, ensure_ascii=False))
25
+ else:
26
+ console.print(data)
27
+ else:
28
+ print(data)
29
+
30
+
31
+ def print_ping_result(data: Dict[str, Any], as_json: bool = False):
32
+ if as_json:
33
+ print(json.dumps(data, indent=2, ensure_ascii=False))
34
+ return
35
+
36
+ status = data.get("status")
37
+ auth = data.get("authenticated", False)
38
+
39
+ if HAS_RICH and console:
40
+ if status == "ok" and auth:
41
+ console.print("[bold green]✓ Servidor LOCKTHARD activo y token autenticado correctamente.[/bold green]")
42
+ elif status == "ok":
43
+ console.print("[bold yellow]! Servidor LOCKTHARD activo (Sesión no autenticada).[/bold yellow]")
44
+ console.print("[dim]Usa 'lockthard login' o exporta LOCKTHARD_API_KEY para autenticar.[/dim]")
45
+ else:
46
+ console.print(f"[bold red]✗ Error de conexión: {data.get('error', 'Desconocido')}[/bold red]")
47
+ else:
48
+ print(f"Status: {status} | Authenticated: {auth}")
49
+
50
+
51
+ def print_tablets_list(tablets: List[Dict[str, Any]], as_json: bool = False):
52
+ if as_json:
53
+ print(json.dumps(tablets, indent=2, ensure_ascii=False))
54
+ return
55
+
56
+ if not tablets:
57
+ print("No se encontraron tablets.")
58
+ return
59
+
60
+ if HAS_RICH and console:
61
+ table = Table(title=f"LOCKTHARD Tablets ({len(tablets)})", show_header=True, header_style="bold magenta")
62
+ table.add_column("ID / Slug", style="cyan", no_wrap=True)
63
+ table.add_column("Título", style="bold white")
64
+ table.add_column("Categoría", style="yellow")
65
+ table.add_column("Herramientas", justify="right", style="green")
66
+
67
+ for t in tablets:
68
+ table.add_row(
69
+ str(t.get("id") or t.get("slug") or "-"),
70
+ str(t.get("title") or t.get("name") or "-"),
71
+ str(t.get("category") or "General"),
72
+ str(t.get("tool_count") or t.get("count") or len(t.get("items", [])))
73
+ )
74
+ console.print(table)
75
+ else:
76
+ for t in tablets:
77
+ print(f"[{t.get('id')}] {t.get('title')} ({t.get('tool_count', 0)} herramientas)")
78
+
79
+
80
+ def print_search_results(query: str, results: List[Dict[str, Any]], as_json: bool = False):
81
+ if as_json:
82
+ print(json.dumps(results, indent=2, ensure_ascii=False))
83
+ return
84
+
85
+ if not results:
86
+ print(f"No se encontraron herramientas para '{query}'.")
87
+ return
88
+
89
+ if HAS_RICH and console:
90
+ table = Table(title=f"Resultados de búsqueda: '{query}' ({len(results)})", show_header=True, header_style="bold cyan")
91
+ table.add_column("Nombre", style="bold green")
92
+ table.add_column("Descripción", style="white")
93
+ table.add_column("URL / Repo", style="blue")
94
+ table.add_column("Tablet", style="dim magenta")
95
+
96
+ for r in results:
97
+ url = r.get("url") or r.get("href") or r.get("repo") or "-"
98
+ desc = r.get("description") or r.get("desc") or ""
99
+ if len(desc) > 80:
100
+ desc = desc[:77] + "..."
101
+ table.add_row(
102
+ str(r.get("name") or "-"),
103
+ desc,
104
+ str(url),
105
+ str(r.get("tablet_id") or r.get("category") or "-")
106
+ )
107
+ console.print(table)
108
+ else:
109
+ for r in results:
110
+ print(f"- {r.get('name')}: {r.get('description', '')} ({r.get('url', '')})")
111
+
112
+
113
+ def print_tablet_content(tablet_data: Dict[str, Any], as_json: bool = False):
114
+ if as_json:
115
+ print(json.dumps(tablet_data, indent=2, ensure_ascii=False))
116
+ return
117
+
118
+ title = tablet_data.get("title") or tablet_data.get("name") or "Tablet"
119
+ items = tablet_data.get("items") or []
120
+
121
+ if HAS_RICH and console:
122
+ console.print(Panel(f"[bold white]{title}[/bold white]\n[dim]{tablet_data.get('description', '')}[/dim]", border_style="cyan"))
123
+
124
+ if items:
125
+ table = Table(title=f"Herramientas en {title} ({len(items)})", show_header=True)
126
+ table.add_column("Nombre", style="bold green")
127
+ table.add_column("Descripción", style="white")
128
+ table.add_column("Enlace", style="blue")
129
+
130
+ for item in items:
131
+ desc = item.get("description") or item.get("desc") or ""
132
+ url = item.get("url") or item.get("href") or item.get("repo") or "-"
133
+ table.add_row(str(item.get("name") or "-"), desc, str(url))
134
+ console.print(table)
135
+ else:
136
+ console.print("[yellow]No hay herramientas registradas en esta tablet.[/yellow]")
137
+ else:
138
+ print(f"=== {title} ===")
139
+ for item in items:
140
+ print(f"- {item.get('name')}: {item.get('url')}")
141
+
142
+
143
+ def print_tool_details(tool: Dict[str, Any], as_json: bool = False):
144
+ if as_json:
145
+ print(json.dumps(tool, indent=2, ensure_ascii=False))
146
+ return
147
+
148
+ if not tool:
149
+ print("Herramienta no encontrada.")
150
+ return
151
+
152
+ if HAS_RICH and console:
153
+ name = tool.get("name", "Desconocido")
154
+ desc = tool.get("description") or tool.get("desc") or ""
155
+ url = tool.get("url") or tool.get("href") or tool.get("repo") or ""
156
+ commands = tool.get("commands") or tool.get("command") or []
157
+
158
+ content = f"[bold green]{name}[/bold green]\n\n{desc}\n\n[bold blue]URL:[/bold blue] {url}"
159
+ if commands:
160
+ if isinstance(commands, list):
161
+ cmds_str = "\n".join([f" $ {c}" for c in commands])
162
+ else:
163
+ cmds_str = f" $ {commands}"
164
+ content += f"\n\n[bold yellow]Comandos recomendados:[/bold yellow]\n[cyan]{cmds_str}[/cyan]"
165
+
166
+ console.print(Panel(content, title="Detalles de Herramienta", border_style="green"))
167
+ else:
168
+ print(f"Nombre: {tool.get('name')}")
169
+ print(f"Descripción: {tool.get('description')}")
170
+ print(f"URL: {tool.get('url')}")
171
+
172
+
173
+ def print_cli_tools(tools: List[Dict[str, Any]], as_json: bool = False):
174
+ if as_json:
175
+ print(json.dumps(tools, indent=2, ensure_ascii=False))
176
+ return
177
+
178
+ if HAS_RICH and console:
179
+ table = Table(title=f"Herramientas CLI ({len(tools)})", show_header=True, header_style="bold yellow")
180
+ table.add_column("Herramienta CLI", style="bold green")
181
+ table.add_column("Comando", style="cyan")
182
+ table.add_column("Descripción", style="white")
183
+
184
+ for t in tools:
185
+ cmd = t.get("command") or t.get("setup") or "-"
186
+ if isinstance(cmd, list):
187
+ cmd = " | ".join(cmd[:2])
188
+ table.add_row(str(t.get("name") or "-"), str(cmd), str(t.get("description") or "-"))
189
+ console.print(table)
190
+ else:
191
+ for t in tools:
192
+ print(f"[{t.get('name')}] {t.get('command')} - {t.get('description')}")
193
+
194
+
195
+ def print_agent_guide():
196
+ guide_md = """
197
+ # LOCKTHARD CLI - Guía para Agentes de IA
198
+
199
+ Bienvenido a la CLI de **LOCKTHARD**. Esta interfaz permite a Agentes de IA (Claude Code, Antigravity, Aider, Cursor) consultar la base de datos de 1200+ herramientas IA con mínimo consumo de tokens.
200
+
201
+ ## Buenas Prácticas para Agentes
202
+
203
+ 1. **Usa el flag `--json` (o `-j`):**
204
+ Devuelve datos limpios y estructurados en JSON en lugar de tablas formateadas con ANSI.
205
+ ```bash
206
+ lockthard search "ocr" --json
207
+ ```
208
+
209
+ 2. **Flujo de Trabajo Recomendado:**
210
+ - `lockthard ping`: Verificar conexión y autenticación.
211
+ - `lockthard tablets list`: Ver todos los tablets y categorías disponibles.
212
+ - `lockthard search "<termino>"`: Buscar herramientas por palabras clave.
213
+ - `lockthard tablets get <id>`: Obtener todas las herramientas de un tablet específico.
214
+ - `lockthard tools get "<nombre>"`: Ver detalles y comandos exactos de una herramienta.
215
+
216
+ 3. **Autenticación en Agentes:**
217
+ Define la variable de entorno `LOCKTHARD_API_KEY`:
218
+ ```bash
219
+ export LOCKTHARD_API_KEY="lk_tu_token_aqui"
220
+ ```
221
+ """
222
+ if HAS_RICH and console:
223
+ console.print(Markdown(guide_md))
224
+ else:
225
+ print(guide_md)
226
+
227
+
228
+ def print_agent_skill():
229
+ skill_content = """---
230
+ name: lockthard
231
+ description: "Scriptable CLI and AI agent client for LOCKTHARD (lockthard.es). Allows searching 2100+ AI tools, exploring 81+ tablets, retrieving tool installation commands, and inspecting tool details with minimum token overhead."
232
+ version: 0.1.0
233
+ ---
234
+
235
+ # LOCKTHARD CLI - Agent Skill Guide
236
+
237
+ Use this CLI to query the LOCKTHARD AI tools directory, find specific tools, get installation/setup commands, and browse tablets.
238
+
239
+ ## Authentication
240
+ Set the environment variable `LOCKTHARD_API_KEY`:
241
+ ```bash
242
+ export LOCKTHARD_API_KEY="lk_your_token"
243
+ ```
244
+
245
+ ## Recommended Workflow for AI Agents
246
+ Always use the `--json` (`-j`) flag to receive clean JSON output.
247
+
248
+ 1. **Verify status:** `lockthard ping`
249
+ 2. **Search tools:** `lockthard search "ocr" --json`
250
+ 3. **List all tablets:** `lockthard tablets list --json`
251
+ 4. **Get tablet tools:** `lockthard tablets get ocr --json`
252
+ 5. **Get tool details:** `lockthard tools get "Chandra OCR" --json`
253
+ 6. **List CLI tools:** `lockthard cli-tools --json`
254
+
255
+ ## Commands Reference
256
+
257
+ | Command | Description | Example |
258
+ |---|---|---|
259
+ | `lockthard ping` | Check connection & auth status | `lockthard ping` |
260
+ | `lockthard search <query>` | Search 2100+ tools | `lockthard search "ocr" -j` |
261
+ | `lockthard tablets list` | List 81+ tablets | `lockthard tablets list -j` |
262
+ | `lockthard tablets get <id>` | Inspect tablet content | `lockthard tablets get open-source -j` |
263
+ | `lockthard tools get <id>` | Detailed tool info & CLI commands | `lockthard tools get "copilotkit" -j` |
264
+ | `lockthard cli-tools` | List dedicated CLI tools | `lockthard cli-tools -j` |
265
+ | `lockthard guide` | Show agent guide | `lockthard guide` |
266
+ | `lockthard skill` | Output this SKILL.md definition | `lockthard skill` |
267
+ """
268
+ print(skill_content)
269
+
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockthard-cli
3
+ Version: 0.1.0
4
+ Summary: Super efficient CLI for LOCKTHARD AI tools directory and agents
5
+ Author-email: Lockthard Team <info@lockthard.es>
6
+ License: MIT
7
+ Project-URL: Homepage, https://lockthard.es
8
+ Keywords: cli,ai-tools,agents,mcp,lockthard
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: click>=8.1.0
20
+ Requires-Dist: requests>=2.28.0
21
+ Requires-Dist: rich>=13.0.0
22
+
23
+ # LOCKTHARD CLI (`lockthard`)
24
+
25
+ A super efficient, scriptable command-line interface and AI agent client for **LOCKTHARD** (https://lockthard.es).
26
+
27
+ Allows both human developers and autonomous AI agents (Claude Code, Antigravity, Aider, Cursor, etc.) to query, search, and inspect 61+ tablets and 1200+ AI tools with minimum token usage and zero prompt overhead.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install lockthard-cli
33
+ ```
34
+
35
+ Or run without installing:
36
+
37
+ ```bash
38
+ pipx run lockthard-cli --help
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ### 1. Authentication
44
+ Get your API key from [lockthard.es](https://lockthard.es).
45
+
46
+ - **For AI Agents & CI/CD:**
47
+ ```bash
48
+ export LOCKTHARD_API_KEY="lk_your_token_here"
49
+ ```
50
+ - **Interactive Browser Login (Humans):**
51
+ ```bash
52
+ lockthard login
53
+ ```
54
+ - **Manual Login:**
55
+ ```bash
56
+ lockthard login --token "lk_your_token_here"
57
+ ```
58
+
59
+ ### 2. Commands
60
+
61
+ ```bash
62
+ # Ping service
63
+ lockthard ping
64
+
65
+ # Search tools (returns concise ASCII table)
66
+ lockthard search "ocr"
67
+
68
+ # Search tools in raw JSON (for agents / jq)
69
+ lockthard search "ocr" --json
70
+
71
+ # List all tablets
72
+ lockthard tablets list
73
+
74
+ # Inspect a specific tablet
75
+ lockthard tablets get ocr
76
+
77
+ # Inspect tool details
78
+ lockthard tools get "Chandra OCR"
79
+
80
+ # Display CLI agent guide
81
+ lockthard guide
82
+ ```
83
+
84
+ ## Options
85
+
86
+ - `--json` / `-j`: Output raw JSON without Rich styling.
87
+ - `--url` / `-u`: Override base server URL (default: `https://lockthard.es`).
88
+ - `--token` / `-t`: Override API token.
89
+
90
+ ## License
91
+
92
+ MIT License
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ lockthard_cli/__init__.py
4
+ lockthard_cli/auth.py
5
+ lockthard_cli/cli.py
6
+ lockthard_cli/client.py
7
+ lockthard_cli/config.py
8
+ lockthard_cli/formatters.py
9
+ lockthard_cli.egg-info/PKG-INFO
10
+ lockthard_cli.egg-info/SOURCES.txt
11
+ lockthard_cli.egg-info/dependency_links.txt
12
+ lockthard_cli.egg-info/entry_points.txt
13
+ lockthard_cli.egg-info/requires.txt
14
+ lockthard_cli.egg-info/top_level.txt
15
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lockthard = lockthard_cli.cli:main
@@ -0,0 +1,3 @@
1
+ click>=8.1.0
2
+ requests>=2.28.0
3
+ rich>=13.0.0
@@ -0,0 +1,3 @@
1
+ dist
2
+ lockthard_cli
3
+ tests
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lockthard-cli"
7
+ version = "0.1.0"
8
+ description = "Super efficient CLI for LOCKTHARD AI tools directory and agents"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Lockthard Team", email = "info@lockthard.es" }
14
+ ]
15
+ keywords = ["cli", "ai-tools", "agents", "mcp", "lockthard"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Utilities"
25
+ ]
26
+ dependencies = [
27
+ "click>=8.1.0",
28
+ "requests>=2.28.0",
29
+ "rich>=13.0.0"
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://lockthard.es"
34
+
35
+ [project.scripts]
36
+ lockthard = "lockthard_cli.cli:main"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ import pytest
2
+ from click.testing import CliRunner
3
+ from lockthard_cli.cli import cli
4
+ from lockthard_cli import __version__
5
+
6
+
7
+ def test_cli_version():
8
+ runner = CliRunner()
9
+ result = runner.invoke(cli, ["--version"])
10
+ assert result.exit_code == 0
11
+ assert __version__ in result.output
12
+
13
+
14
+ def test_cli_guide():
15
+ runner = CliRunner()
16
+ result = runner.invoke(cli, ["guide"])
17
+ assert result.exit_code == 0
18
+ assert "Guía para Agentes de IA" in result.output or "LOCKTHARD CLI" in result.output