lockthard-cli 0.1.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.
- lockthard_cli/__init__.py +5 -0
- lockthard_cli/auth.py +159 -0
- lockthard_cli/cli.py +196 -0
- lockthard_cli/client.py +141 -0
- lockthard_cli/config.py +114 -0
- lockthard_cli/formatters.py +269 -0
- lockthard_cli-0.1.0.dist-info/METADATA +92 -0
- lockthard_cli-0.1.0.dist-info/RECORD +12 -0
- lockthard_cli-0.1.0.dist-info/WHEEL +5 -0
- lockthard_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lockthard_cli-0.1.0.dist-info/top_level.txt +2 -0
- tests/test_cli.py +18 -0
lockthard_cli/auth.py
ADDED
|
@@ -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."
|
lockthard_cli/cli.py
ADDED
|
@@ -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()
|
lockthard_cli/client.py
ADDED
|
@@ -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 []
|
lockthard_cli/config.py
ADDED
|
@@ -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,12 @@
|
|
|
1
|
+
lockthard_cli/__init__.py,sha256=tmElGwygHQKQFif_i50RLDA5zIjH-aSUW2HLMnIcK0I,102
|
|
2
|
+
lockthard_cli/auth.py,sha256=SWmmGAIll8FcbGCtjfbGh32gvUaGHnRLMTY-Kaov6BA,5595
|
|
3
|
+
lockthard_cli/cli.py,sha256=MFh30-sCIy0iKzenn7GyVnPlswVEx9HKvsbbPLIj8V8,6800
|
|
4
|
+
lockthard_cli/client.py,sha256=tW6EtQp1kRut7f6YdAKF4qQ-OGcbTE4mrGZIKcVOfHc,6202
|
|
5
|
+
lockthard_cli/config.py,sha256=6IQ0UB9PeON7dqpEj80t-ULXxWXLqfkill0UOk87HfI,3237
|
|
6
|
+
lockthard_cli/formatters.py,sha256=YDLFDQ80gNlEcJg4UTq3OhDs3TnseYFG1rLYtTi6kUc,10423
|
|
7
|
+
tests/test_cli.py,sha256=mJ5mTO7bJu9RBZ0tFVJkj8D84W9xVAN2Hl7vcYR74po,509
|
|
8
|
+
lockthard_cli-0.1.0.dist-info/METADATA,sha256=PauHIBeZZdET13LRQ5xCCTPGJsoKo4k1-05ENq3gabk,2336
|
|
9
|
+
lockthard_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
lockthard_cli-0.1.0.dist-info/entry_points.txt,sha256=CueW23X71Y6pzwWJ7xUs8r1yc2Xi0u0qLvnjur23hHY,53
|
|
11
|
+
lockthard_cli-0.1.0.dist-info/top_level.txt,sha256=ak1Leww6sw_nzaAUSWbg32hDw6szmNFYKeTmSMhtRC8,20
|
|
12
|
+
lockthard_cli-0.1.0.dist-info/RECORD,,
|
tests/test_cli.py
ADDED
|
@@ -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
|