loktar-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.
- loktar_cli/SKILL.md +122 -0
- loktar_cli/__init__.py +5 -0
- loktar_cli/auth.py +159 -0
- loktar_cli/cli.py +242 -0
- loktar_cli/client.py +172 -0
- loktar_cli/config.py +120 -0
- loktar_cli/formatters.py +262 -0
- loktar_cli-0.1.0.dist-info/METADATA +95 -0
- loktar_cli-0.1.0.dist-info/RECORD +13 -0
- loktar_cli-0.1.0.dist-info/WHEEL +5 -0
- loktar_cli-0.1.0.dist-info/entry_points.txt +2 -0
- loktar_cli-0.1.0.dist-info/top_level.txt +2 -0
- tests/test_cli.py +17 -0
loktar_cli/SKILL.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: loktar
|
|
3
|
+
description: "LOKTAR CLI — query 2100+ curated AI tools across 86+ tablets. Search, browse tablets, get tool details, and investigate repos. Designed for both humans and AI agents."
|
|
4
|
+
version: 0.2.8
|
|
5
|
+
metadata:
|
|
6
|
+
hermes:
|
|
7
|
+
tags: [loktar, tools, ai, search, tablets, mcp]
|
|
8
|
+
homepage: https://loktar.cc
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# LOKTAR CLI
|
|
12
|
+
|
|
13
|
+
CLI para consultar el directorio de herramientas IA de LOKTAR (2100+ herramientas, 86+ tablets). Diseñada para humanos (tablas Rich) y agentes IA (JSON limpio).
|
|
14
|
+
|
|
15
|
+
## Autenticación
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Opción 1: Variable de entorno (recomendado para agentes)
|
|
19
|
+
export LOKTAR_API_KEY="lk_tu_token"
|
|
20
|
+
|
|
21
|
+
# Opción 2: Login con token
|
|
22
|
+
loktar login --token lk_tu_token
|
|
23
|
+
|
|
24
|
+
# Opción 3: Login interactivo (abre navegador)
|
|
25
|
+
loktar login
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Token guardado en `~/.loktar/credentials.json` con permisos 0600.
|
|
29
|
+
|
|
30
|
+
## Flag JSON (`-j` / `--json`)
|
|
31
|
+
|
|
32
|
+
Para agentes IA: **siempre usa `-j`** para recibir JSON limpio sin formato ANSI.
|
|
33
|
+
|
|
34
|
+
El flag funciona tanto a nivel global como en cualquier subcommand:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
loktar -j tablets list # ✅ global
|
|
38
|
+
loktar tablets list -j # ✅ en subcommand
|
|
39
|
+
loktar -j tablets get vector-knowledge-base # ✅
|
|
40
|
+
loktar tablets get vector-knowledge-base -j # ✅
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Comandos
|
|
44
|
+
|
|
45
|
+
| Comando | Descripción |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `loktar ping` | Verificar conexión y autenticación |
|
|
48
|
+
| `loktar -j search "<query>"` | Buscar herramientas por palabra clave |
|
|
49
|
+
| `loktar -j tablets list` | Listar todos los tablets disponibles |
|
|
50
|
+
| `loktar -j tablets get <tablet_id>` | Ver herramientas de un tablet |
|
|
51
|
+
| `loktar -j tools get "<tool_id>"` | Detalles de una herramienta específica |
|
|
52
|
+
| `loktar -j cli-tools` | Listar herramientas CLI dedicadas |
|
|
53
|
+
| `loktar guide` | Guía rápida para agentes |
|
|
54
|
+
| `loktar skill` | Muestra esta SKILL.md completa |
|
|
55
|
+
|
|
56
|
+
## Estructura de un Tablet
|
|
57
|
+
|
|
58
|
+
Cada tablet es una colección curada de herramientas agrupadas por temática. Al obtener un tablet (`loktar -j tablets get <id>`), recibes:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"id": "vector-knowledge-base",
|
|
63
|
+
"title": "Vector Knowledge Base",
|
|
64
|
+
"toolCount": 41,
|
|
65
|
+
"tools": [
|
|
66
|
+
{
|
|
67
|
+
"name": "Graphify",
|
|
68
|
+
"description": "Transforma un proyecto en un knowledge graph...",
|
|
69
|
+
"url": "https://github.com/safishamsi/graphify",
|
|
70
|
+
"section": "General",
|
|
71
|
+
"snippetsCount": 1
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Cómo interpretar las herramientas
|
|
78
|
+
|
|
79
|
+
- **`section`**: Categoría que agrupa la herramienta. **La sección YA es contexto** — si una herramienta está en "Memoria Y Contexto Para Agentes", eso ya indica su dominio.
|
|
80
|
+
- **`description`**: Descripción textual (puede estar vacía si el nombre + sección + URL son suficientes).
|
|
81
|
+
- **`url`**: Enlace al repositorio o sitio web. **El agente debe investigar esta URL** para obtener más detalles (leer README, verificar instalación, etc.).
|
|
82
|
+
- **`snippetsCount`**: Número de snippets de código o instrucciones adicionales disponibles vía `tools get`.
|
|
83
|
+
|
|
84
|
+
### Ejemplo: interpretación correcta
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Herramienta: "LightRAG"
|
|
88
|
+
Sección: "RAG" (o "General")
|
|
89
|
+
URL: https://github.com/HKUDS/LightRAG
|
|
90
|
+
Descripción: (vacía)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Interpretación correcta:** Es un sistema RAG. La sección indica que es de la categoría RAG. La URL apunta a GitHub donde el agente puede leer el README para entender qué hace.
|
|
94
|
+
|
|
95
|
+
**Interpretación incorrecta:** ❌ "Herramienta sin descripción, está incompleta."
|
|
96
|
+
|
|
97
|
+
## Flujo de trabajo recomendado para agentes
|
|
98
|
+
|
|
99
|
+
1. `loktar ping` — verificar auth
|
|
100
|
+
2. `loktar -j tablets list` — ver tablets disponibles
|
|
101
|
+
3. `loktar -j tablets get <id>` — explorar herramientas de un tablet relevante
|
|
102
|
+
4. Para cada herramienta interesante: **ir al repo/URL y leer documentación**
|
|
103
|
+
5. `loktar -j tools get "<nombre>"` — detalles específicos con snippets si se necesitan
|
|
104
|
+
|
|
105
|
+
## Ejemplos de uso
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# Buscar herramientas de OCR
|
|
109
|
+
loktar -j search "ocr"
|
|
110
|
+
|
|
111
|
+
# Ver todo el tablet de cybersecurity
|
|
112
|
+
loktar -j tablets get cybersecurity
|
|
113
|
+
|
|
114
|
+
# Ver detalles de una herramienta específica
|
|
115
|
+
loktar -j tools get "Graphify"
|
|
116
|
+
|
|
117
|
+
# Listar herramientas CLI
|
|
118
|
+
loktar -j cli-tools
|
|
119
|
+
|
|
120
|
+
# Verificar que todo funciona
|
|
121
|
+
loktar ping
|
|
122
|
+
```
|
loktar_cli/__init__.py
ADDED
loktar_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>LOKTAR</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 loktar.cc/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 LOKTAR...")
|
|
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."
|
loktar_cli/cli.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .config import resolve_token, resolve_api_url, save_credentials, clear_credentials
|
|
9
|
+
from .auth import perform_browser_login
|
|
10
|
+
from .client import LoktarClient, LoktarError
|
|
11
|
+
from .formatters import (
|
|
12
|
+
output_data,
|
|
13
|
+
print_ping_result,
|
|
14
|
+
print_tablets_list,
|
|
15
|
+
print_search_results,
|
|
16
|
+
print_tablet_content,
|
|
17
|
+
print_tool_details,
|
|
18
|
+
print_cli_tools,
|
|
19
|
+
print_agent_guide,
|
|
20
|
+
HAS_RICH,
|
|
21
|
+
console,
|
|
22
|
+
)
|
|
23
|
+
from rich.markdown import Markdown
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _handle_error(err: Exception):
|
|
27
|
+
"""Print clean error message without traceback."""
|
|
28
|
+
click.secho(f"Error: {err}", fg="red", err=True)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_as_json(ctx: click.Context, local_flag: bool) -> bool:
|
|
33
|
+
"""Merge global --json with local subcommand --json flag."""
|
|
34
|
+
return ctx.obj.get("as_json", False) or local_flag
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group(invoke_without_command=False,
|
|
38
|
+
epilog="Primer uso: loktar skill | Para agentes: loktar -j skill")
|
|
39
|
+
@click.option("--url", "-u", default=None, help="Base URL del servidor LOKTAR (default: https://loktar.cc).")
|
|
40
|
+
@click.option("--token", "-t", default=None, help="API Key / Token de autenticación (lk_...).")
|
|
41
|
+
@click.option("--json", "-j", "as_json", is_flag=True, help="Salida en JSON bruto (ideal para scripts y agentes).")
|
|
42
|
+
@click.version_option(version=__version__, prog_name="loktar")
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def cli(ctx: click.Context, url: Optional[str], token: Optional[str], as_json: bool):
|
|
45
|
+
"""LOKTAR CLI - Cliente super eficiente de herramientas IA para humanos y agentes.
|
|
46
|
+
|
|
47
|
+
Primer uso: loktar skill | Para agentes: loktar -j skill
|
|
48
|
+
"""
|
|
49
|
+
resolved_url = resolve_api_url(url)
|
|
50
|
+
resolved_token = resolve_token(token)
|
|
51
|
+
|
|
52
|
+
ctx.ensure_object(dict)
|
|
53
|
+
ctx.obj["url"] = resolved_url
|
|
54
|
+
ctx.obj["token"] = resolved_token
|
|
55
|
+
ctx.obj["as_json"] = as_json
|
|
56
|
+
ctx.obj["client"] = LoktarClient(base_url=resolved_url, token=resolved_token)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@cli.command()
|
|
60
|
+
@click.option("--json", "-j", "as_json_local", is_flag=True, default=False,
|
|
61
|
+
help="Salida en JSON bruto (ideal para scripts y agentes).")
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def ping(ctx: click.Context, as_json_local: bool):
|
|
64
|
+
"""Verifica la conectividad con el servidor de LOKTAR y el estado del token."""
|
|
65
|
+
client: LoktarClient = ctx.obj["client"]
|
|
66
|
+
as_json = _get_as_json(ctx, as_json_local)
|
|
67
|
+
try:
|
|
68
|
+
res = client.ping()
|
|
69
|
+
print_ping_result(res, as_json=as_json)
|
|
70
|
+
except LoktarError as e:
|
|
71
|
+
_handle_error(e)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@cli.command()
|
|
75
|
+
@click.option("--token", "-t", help="API Key directa (lk_...) para guardar sin interacción.")
|
|
76
|
+
@click.option("--manual", "-m", is_flag=True, help="Modo interactivo en consola (sin abrir navegador).")
|
|
77
|
+
@click.pass_context
|
|
78
|
+
def login(ctx: click.Context, token: Optional[str], manual: bool):
|
|
79
|
+
"""Inicia sesión en LOKTAR (Browser OAuth, Token o Interactivo)."""
|
|
80
|
+
api_url = ctx.obj["url"]
|
|
81
|
+
|
|
82
|
+
# 1. Direct token via flag
|
|
83
|
+
if token and token.strip():
|
|
84
|
+
save_credentials(api_key=token.strip(), api_url=api_url)
|
|
85
|
+
click.secho("✓ API Key guardada correctamente en ~/.loktar/credentials.json", fg="green")
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# 2. Manual prompt via flag
|
|
89
|
+
if manual:
|
|
90
|
+
token_input = click.prompt("Pega tu API Key de LOKTAR (lk_...)", hide_input=True)
|
|
91
|
+
if not token_input or not token_input.strip():
|
|
92
|
+
_handle_error(LoktarError("Token vacío."))
|
|
93
|
+
save_credentials(api_key=token_input.strip(), api_url=api_url)
|
|
94
|
+
click.secho("✓ API Key guardada correctamente en ~/.loktar/credentials.json", fg="green")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# 3. Interactive Browser Login
|
|
98
|
+
click.echo(f"Iniciando flujo de autenticación para {api_url}...")
|
|
99
|
+
success, msg = perform_browser_login(api_url)
|
|
100
|
+
if success:
|
|
101
|
+
click.secho(f"✓ {msg}", fg="green")
|
|
102
|
+
else:
|
|
103
|
+
click.secho(f"✗ {msg}", fg="yellow")
|
|
104
|
+
click.echo("Puedes autenticarte manualmente con: loktar login --token lk_tu_token")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@cli.command()
|
|
108
|
+
def logout():
|
|
109
|
+
"""Cierra la sesión y elimina las credenciales locales."""
|
|
110
|
+
if clear_credentials():
|
|
111
|
+
click.secho("✓ Sesión cerrada y credenciales eliminadas (~/.loktar/credentials.json).", fg="green")
|
|
112
|
+
else:
|
|
113
|
+
click.echo("No había ninguna sesión activa guardada.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@cli.command()
|
|
117
|
+
@click.option("--json", "-j", "as_json_local", is_flag=True, default=False,
|
|
118
|
+
help="Salida en JSON bruto (ideal para scripts y agentes).")
|
|
119
|
+
@click.argument("query")
|
|
120
|
+
@click.option("--tablet", "-t", help="Filtrar por ID de tablet específica.")
|
|
121
|
+
@click.option("--limit", "-l", default=20, type=int, help="Límite de resultados (default: 20).")
|
|
122
|
+
@click.pass_context
|
|
123
|
+
def search(ctx: click.Context, as_json_local: bool, query: str, tablet: Optional[str], limit: int):
|
|
124
|
+
"""Busca herramientas por palabra clave en LOKTAR."""
|
|
125
|
+
client: LoktarClient = ctx.obj["client"]
|
|
126
|
+
as_json = _get_as_json(ctx, as_json_local)
|
|
127
|
+
try:
|
|
128
|
+
results = client.search_tools(query=query, tablet_id=tablet, limit=limit)
|
|
129
|
+
print_search_results(query, results, as_json=as_json)
|
|
130
|
+
except LoktarError as e:
|
|
131
|
+
_handle_error(e)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@cli.group()
|
|
135
|
+
def tablets():
|
|
136
|
+
"""Comandos para explorar las tablets de LOKTAR."""
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@tablets.command(name="list")
|
|
141
|
+
@click.option("--json", "-j", "as_json_local", is_flag=True, default=False,
|
|
142
|
+
help="Salida en JSON bruto (ideal para scripts y agentes).")
|
|
143
|
+
@click.option("--query", "-q", help="Filtrar tablets por título o categoría.")
|
|
144
|
+
@click.pass_context
|
|
145
|
+
def list_tablets_cmd(ctx: click.Context, as_json_local: bool, query: Optional[str]):
|
|
146
|
+
"""Lista los 61+ tablets y categorías disponibles."""
|
|
147
|
+
client: LoktarClient = ctx.obj["client"]
|
|
148
|
+
as_json = _get_as_json(ctx, as_json_local)
|
|
149
|
+
try:
|
|
150
|
+
res = client.list_tablets(query=query)
|
|
151
|
+
print_tablets_list(res, as_json=as_json)
|
|
152
|
+
except LoktarError as e:
|
|
153
|
+
_handle_error(e)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@tablets.command(name="get")
|
|
157
|
+
@click.option("--json", "-j", "as_json_local", is_flag=True, default=False,
|
|
158
|
+
help="Salida en JSON bruto (ideal para scripts y agentes).")
|
|
159
|
+
@click.argument("tablet_id")
|
|
160
|
+
@click.option("--limit", "-l", type=int, help="Límite de herramientas a devolver.")
|
|
161
|
+
@click.pass_context
|
|
162
|
+
def get_tablet_cmd(ctx: click.Context, as_json_local: bool, tablet_id: str, limit: Optional[int]):
|
|
163
|
+
"""Obtiene el contenido completo de un tablet por su ID (ej. ocr, open-source)."""
|
|
164
|
+
client: LoktarClient = ctx.obj["client"]
|
|
165
|
+
as_json = _get_as_json(ctx, as_json_local)
|
|
166
|
+
try:
|
|
167
|
+
res = client.get_tablet(tablet_id=tablet_id, limit=limit)
|
|
168
|
+
print_tablet_content(res, as_json=as_json)
|
|
169
|
+
except LoktarError as e:
|
|
170
|
+
_handle_error(e)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@cli.group()
|
|
174
|
+
def tools():
|
|
175
|
+
"""Comandos para inspeccionar herramientas individuales."""
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@tools.command(name="get")
|
|
180
|
+
@click.option("--json", "-j", "as_json_local", is_flag=True, default=False,
|
|
181
|
+
help="Salida en JSON bruto (ideal para scripts y agentes).")
|
|
182
|
+
@click.argument("tool_id")
|
|
183
|
+
@click.pass_context
|
|
184
|
+
def get_tool_cmd(ctx: click.Context, as_json_local: bool, tool_id: str):
|
|
185
|
+
"""Obtiene los detalles y comandos recomendados de una herramienta específica."""
|
|
186
|
+
client: LoktarClient = ctx.obj["client"]
|
|
187
|
+
as_json = _get_as_json(ctx, as_json_local)
|
|
188
|
+
try:
|
|
189
|
+
res = client.get_tool_details(tool_id=tool_id)
|
|
190
|
+
print_tool_details(res, as_json=as_json)
|
|
191
|
+
except LoktarError as e:
|
|
192
|
+
_handle_error(e)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@cli.command(name="cli-tools")
|
|
196
|
+
@click.option("--json", "-j", "as_json_local", is_flag=True, default=False,
|
|
197
|
+
help="Salida en JSON bruto (ideal para scripts y agentes).")
|
|
198
|
+
@click.pass_context
|
|
199
|
+
def cli_tools_cmd(ctx: click.Context, as_json_local: bool):
|
|
200
|
+
"""Lista las herramientas especializadas de línea de comandos en LOKTAR."""
|
|
201
|
+
client: LoktarClient = ctx.obj["client"]
|
|
202
|
+
as_json = _get_as_json(ctx, as_json_local)
|
|
203
|
+
try:
|
|
204
|
+
res = client.get_cli_tools()
|
|
205
|
+
print_cli_tools(res, as_json=as_json)
|
|
206
|
+
except LoktarError as e:
|
|
207
|
+
_handle_error(e)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@cli.command()
|
|
211
|
+
def guide():
|
|
212
|
+
"""Muestra la guía rápida de la CLI optimizada para Agentes de IA."""
|
|
213
|
+
print_agent_guide()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@cli.command()
|
|
217
|
+
@click.pass_context
|
|
218
|
+
def skill(ctx: click.Context):
|
|
219
|
+
"""Muestra la SKILL.md integrada del paquete para que un Agente de IA la importe."""
|
|
220
|
+
try:
|
|
221
|
+
skill_path = Path(__file__).parent / "SKILL.md"
|
|
222
|
+
if not skill_path.exists():
|
|
223
|
+
raise FileNotFoundError(f"SKILL.md no encontrada en {skill_path}")
|
|
224
|
+
content = skill_path.read_text(encoding="utf-8")
|
|
225
|
+
|
|
226
|
+
if ctx.obj.get("as_json"):
|
|
227
|
+
print(json.dumps({"skill": content}, ensure_ascii=False))
|
|
228
|
+
else:
|
|
229
|
+
if HAS_RICH and console:
|
|
230
|
+
console.print(Markdown(content))
|
|
231
|
+
else:
|
|
232
|
+
print(content)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
_handle_error(LoktarError(f"No se pudo leer SKILL.md: {e}"))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main():
|
|
238
|
+
cli(obj={})
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
main()
|
loktar_cli/client.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LoktarError(Exception):
|
|
7
|
+
"""Base exception for LOKTAR CLI client errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LoktarClient:
|
|
12
|
+
"""Pure data layer for communicating with the LOKTAR API backend."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, base_url: str = "https://loktar.cc", 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 LoktarError("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 LoktarError(f"No se pudo conectar con el servidor en {self.base_url}")
|
|
36
|
+
except requests.Timeout:
|
|
37
|
+
raise LoktarError(f"Tiempo de espera agotado ({self.timeout}s) conectando a {self.base_url}")
|
|
38
|
+
except requests.HTTPError as e:
|
|
39
|
+
raise LoktarError(f"Error HTTP del servidor: {e}")
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise LoktarError("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 LOKTAR 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 LoktarError("No autorizado: Token inválido. Inicia sesión con 'loktar login'.")
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
data = resp.json()
|
|
59
|
+
if "error" in data:
|
|
60
|
+
err = data["error"]
|
|
61
|
+
raise LoktarError(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 LoktarError(f"No se pudo conectar con el servidor en {self.base_url}")
|
|
76
|
+
except requests.Timeout:
|
|
77
|
+
raise LoktarError(f"Tiempo de espera agotado ({self.timeout}s) conectando a {self.base_url}")
|
|
78
|
+
except requests.HTTPError as e:
|
|
79
|
+
raise LoktarError(f"Error HTTP del servidor: {e}")
|
|
80
|
+
except ValueError:
|
|
81
|
+
raise LoktarError("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 LoktarError 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
|
+
if isinstance(res, dict):
|
|
118
|
+
return res.get("tablet") or res
|
|
119
|
+
return {"tools": res if isinstance(res, list) else []}
|
|
120
|
+
|
|
121
|
+
def search_tools(self, query: str, tablet_id: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
|
|
122
|
+
"""Search across all tools in LOKTAR directory."""
|
|
123
|
+
args: Dict[str, Any] = {"query": query, "limit": limit}
|
|
124
|
+
if tablet_id:
|
|
125
|
+
args["tablet_id"] = tablet_id
|
|
126
|
+
res = self._mcp_call("tools/call", {
|
|
127
|
+
"name": "search_tools",
|
|
128
|
+
"arguments": args
|
|
129
|
+
})
|
|
130
|
+
if isinstance(res, dict):
|
|
131
|
+
return res.get("results") or res.get("tools") or []
|
|
132
|
+
return res if isinstance(res, list) else []
|
|
133
|
+
|
|
134
|
+
def get_tool_details(self, tool_id: str) -> Dict[str, Any]:
|
|
135
|
+
"""Get full details of a specific tool by ID or name.
|
|
136
|
+
|
|
137
|
+
If tool_id doesn't look like a full ID (tablet:slug:index),
|
|
138
|
+
first search by name to resolve the ID.
|
|
139
|
+
"""
|
|
140
|
+
# If it's not a full ID, search first to resolve it
|
|
141
|
+
if ":" not in tool_id:
|
|
142
|
+
results = self.search_tools(query=tool_id, limit=5)
|
|
143
|
+
# Try exact name match first, then partial
|
|
144
|
+
for r in results:
|
|
145
|
+
if r.get("name", "").lower() == tool_id.lower():
|
|
146
|
+
tool_id = r["id"]
|
|
147
|
+
break
|
|
148
|
+
else:
|
|
149
|
+
if results:
|
|
150
|
+
tool_id = results[0]["id"]
|
|
151
|
+
|
|
152
|
+
res = self._mcp_call("tools/call", {
|
|
153
|
+
"name": "get_tool_details",
|
|
154
|
+
"arguments": {"tool_id": tool_id}
|
|
155
|
+
})
|
|
156
|
+
if isinstance(res, dict):
|
|
157
|
+
return res.get("tool") or res
|
|
158
|
+
return {}
|
|
159
|
+
|
|
160
|
+
def get_cli_tools(self) -> List[Dict[str, Any]]:
|
|
161
|
+
"""Fetch all CLI specific tools from LOKTAR."""
|
|
162
|
+
try:
|
|
163
|
+
res = self._mcp_call("tools/call", {
|
|
164
|
+
"name": "get_cli_tools_data",
|
|
165
|
+
"arguments": {}
|
|
166
|
+
})
|
|
167
|
+
if isinstance(res, list) and res:
|
|
168
|
+
return res
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
res = self._get("/api/cli-tools")
|
|
172
|
+
return res if isinstance(res, list) else []
|
loktar_cli/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
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://loktar.cc"
|
|
8
|
+
CONFIG_DIR_NAME = ".loktar"
|
|
9
|
+
CREDENTIALS_FILE_NAME = "credentials.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_config_dir() -> Path:
|
|
13
|
+
"""Return the configuration directory path (~/.loktar)."""
|
|
14
|
+
home = Path.home()
|
|
15
|
+
primary = home / CONFIG_DIR_NAME
|
|
16
|
+
primary.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
return primary
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_credentials_path() -> Path:
|
|
21
|
+
"""Return the credentials file path (~/.loktar/credentials.json)."""
|
|
22
|
+
return get_config_dir() / CREDENTIALS_FILE_NAME
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_credentials() -> Dict[str, Any]:
|
|
26
|
+
"""Load credentials from ~/.loktar/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
|
+
home = Path.home()
|
|
40
|
+
primary_dir = home / CONFIG_DIR_NAME
|
|
41
|
+
primary_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
path = primary_dir / CREDENTIALS_FILE_NAME
|
|
43
|
+
|
|
44
|
+
data = load_credentials()
|
|
45
|
+
data["api_key"] = api_key.strip()
|
|
46
|
+
if email:
|
|
47
|
+
data["email"] = email
|
|
48
|
+
if api_url:
|
|
49
|
+
data["api_url"] = api_url.rstrip("/")
|
|
50
|
+
|
|
51
|
+
# Write json file
|
|
52
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
# Set 0600 permissions on POSIX
|
|
55
|
+
if os.name != "nt":
|
|
56
|
+
try:
|
|
57
|
+
os.chmod(path, 0o600)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clear_credentials() -> bool:
|
|
65
|
+
"""Remove the credentials file on logout."""
|
|
66
|
+
removed = False
|
|
67
|
+
home = Path.home()
|
|
68
|
+
path = home / CONFIG_DIR_NAME / CREDENTIALS_FILE_NAME
|
|
69
|
+
if path.exists():
|
|
70
|
+
try:
|
|
71
|
+
path.unlink()
|
|
72
|
+
removed = True
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
return removed
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_token(token_flag: Optional[str] = None) -> Optional[str]:
|
|
79
|
+
"""
|
|
80
|
+
Resolve API key token in precedence order:
|
|
81
|
+
1. Direct CLI argument / flag (--token)
|
|
82
|
+
2. Environment variable LOKTAR_API_KEY
|
|
83
|
+
3. Saved credentials (~/.loktar/credentials.json)
|
|
84
|
+
"""
|
|
85
|
+
if token_flag and token_flag.strip():
|
|
86
|
+
return token_flag.strip()
|
|
87
|
+
|
|
88
|
+
env_token = os.environ.get("LOKTAR_API_KEY")
|
|
89
|
+
if env_token and env_token.strip():
|
|
90
|
+
return env_token.strip()
|
|
91
|
+
|
|
92
|
+
creds = load_credentials()
|
|
93
|
+
saved_token = creds.get("api_key")
|
|
94
|
+
if saved_token and str(saved_token).strip():
|
|
95
|
+
return str(saved_token).strip()
|
|
96
|
+
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_api_url(url_flag: Optional[str] = None) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Resolve API Base URL in precedence order:
|
|
103
|
+
1. Direct CLI argument / flag (--url)
|
|
104
|
+
2. Environment variable LOKTAR_API_URL
|
|
105
|
+
3. Saved credentials api_url
|
|
106
|
+
4. Default (https://loktar.cc)
|
|
107
|
+
"""
|
|
108
|
+
if url_flag and url_flag.strip():
|
|
109
|
+
return url_flag.strip().rstrip("/")
|
|
110
|
+
|
|
111
|
+
env_url = os.environ.get("LOKTAR_API_URL")
|
|
112
|
+
if env_url and env_url.strip():
|
|
113
|
+
return env_url.strip().rstrip("/")
|
|
114
|
+
|
|
115
|
+
creds = load_credentials()
|
|
116
|
+
saved_url = creds.get("api_url")
|
|
117
|
+
if saved_url and str(saved_url).strip():
|
|
118
|
+
return str(saved_url).strip().rstrip("/")
|
|
119
|
+
|
|
120
|
+
return DEFAULT_API_URL
|
loktar_cli/formatters.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
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 LOKTAR activo y token autenticado correctamente.[/bold green]")
|
|
42
|
+
elif status == "ok":
|
|
43
|
+
console.print("[bold yellow]! Servidor LOKTAR activo (Sesión no autenticada).[/bold yellow]")
|
|
44
|
+
console.print("[dim]Usa 'loktar login' o exporta LOKTAR_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"LOKTAR 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
|
+
count = t.get("toolCount") or t.get("tool_count") or t.get("count") or len(t.get("tools") or t.get("items") or [])
|
|
69
|
+
table.add_row(
|
|
70
|
+
str(t.get("id") or t.get("slug") or "-"),
|
|
71
|
+
str(t.get("title") or t.get("name") or "-"),
|
|
72
|
+
str(t.get("category") or "General"),
|
|
73
|
+
str(count)
|
|
74
|
+
)
|
|
75
|
+
console.print(table)
|
|
76
|
+
else:
|
|
77
|
+
for t in tablets:
|
|
78
|
+
count = t.get("toolCount") or t.get("tool_count") or t.get("count") or len(t.get("tools") or t.get("items") or [])
|
|
79
|
+
print(f"[{t.get('id')}] {t.get('title')} ({count} herramientas)")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def print_search_results(query: str, results: List[Dict[str, Any]], as_json: bool = False):
|
|
83
|
+
if as_json:
|
|
84
|
+
print(json.dumps(results, indent=2, ensure_ascii=False))
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if not results:
|
|
88
|
+
print(f"No se encontraron herramientas para '{query}'.")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if HAS_RICH and console:
|
|
92
|
+
table = Table(title=f"Resultados de búsqueda: '{query}' ({len(results)})", show_header=True, header_style="bold cyan")
|
|
93
|
+
table.add_column("Nombre", style="bold green")
|
|
94
|
+
table.add_column("Descripción", style="white")
|
|
95
|
+
table.add_column("URL / Repo", style="blue")
|
|
96
|
+
table.add_column("Tablet", style="dim magenta")
|
|
97
|
+
|
|
98
|
+
for r in results:
|
|
99
|
+
url = r.get("url") or r.get("href") or r.get("repo") or "-"
|
|
100
|
+
desc = r.get("description") or r.get("desc") or ""
|
|
101
|
+
if len(desc) > 80:
|
|
102
|
+
desc = desc[:77] + "..."
|
|
103
|
+
table.add_row(
|
|
104
|
+
str(r.get("name") or "-"),
|
|
105
|
+
desc,
|
|
106
|
+
str(url),
|
|
107
|
+
str(r.get("tablet_id") or r.get("category") or "-")
|
|
108
|
+
)
|
|
109
|
+
console.print(table)
|
|
110
|
+
else:
|
|
111
|
+
for r in results:
|
|
112
|
+
print(f"- {r.get('name')}: {r.get('description', '')} ({r.get('url', '')})")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_tablet_content(tablet_data: Dict[str, Any], as_json: bool = False):
|
|
116
|
+
if isinstance(tablet_data, dict) and "tablet" in tablet_data and isinstance(tablet_data["tablet"], dict):
|
|
117
|
+
tablet_data = tablet_data["tablet"]
|
|
118
|
+
|
|
119
|
+
if as_json:
|
|
120
|
+
print(json.dumps(tablet_data, indent=2, ensure_ascii=False))
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
title = tablet_data.get("title") or tablet_data.get("name") or "Tablet"
|
|
124
|
+
items = tablet_data.get("tools") or tablet_data.get("items") or []
|
|
125
|
+
|
|
126
|
+
if HAS_RICH and console:
|
|
127
|
+
console.print(Panel(f"[bold white]{title}[/bold white]\n[dim]{tablet_data.get('description', '')}[/dim]", border_style="cyan"))
|
|
128
|
+
|
|
129
|
+
if items:
|
|
130
|
+
# Group items by section
|
|
131
|
+
sections: Dict[str, List[Dict[str, Any]]] = {}
|
|
132
|
+
for item in items:
|
|
133
|
+
sec_name = item.get("section") or "General"
|
|
134
|
+
sections.setdefault(sec_name, []).append(item)
|
|
135
|
+
|
|
136
|
+
for sec_title, sec_items in sections.items():
|
|
137
|
+
section_header = f"📁 {sec_title}" if sec_title != "General" else f"🛠️ Herramientas ({len(sec_items)})"
|
|
138
|
+
table = Table(title=section_header, show_header=True, header_style="bold yellow")
|
|
139
|
+
table.add_column("Nombre", style="bold green")
|
|
140
|
+
table.add_column("Descripción", style="white")
|
|
141
|
+
table.add_column("Enlace", style="blue")
|
|
142
|
+
|
|
143
|
+
for item in sec_items:
|
|
144
|
+
desc = item.get("description") or item.get("desc") or ""
|
|
145
|
+
url = item.get("url") or item.get("href") or item.get("repo") or "-"
|
|
146
|
+
table.add_row(str(item.get("name") or "-"), desc, str(url))
|
|
147
|
+
console.print(table)
|
|
148
|
+
else:
|
|
149
|
+
console.print("[yellow]No hay herramientas registradas en esta tablet.[/yellow]")
|
|
150
|
+
else:
|
|
151
|
+
print(f"=== {title} ===")
|
|
152
|
+
sections: Dict[str, List[Dict[str, Any]]] = {}
|
|
153
|
+
for item in items:
|
|
154
|
+
sec_name = item.get("section") or "General"
|
|
155
|
+
sections.setdefault(sec_name, []).append(item)
|
|
156
|
+
for sec_title, sec_items in sections.items():
|
|
157
|
+
print(f"\n--- {sec_title} ---")
|
|
158
|
+
for item in sec_items:
|
|
159
|
+
print(f"- {item.get('name')}: {item.get('url')}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def print_tool_details(tool: Dict[str, Any], as_json: bool = False):
|
|
163
|
+
if isinstance(tool, dict) and "tool" in tool and isinstance(tool["tool"], dict):
|
|
164
|
+
tool = tool["tool"]
|
|
165
|
+
|
|
166
|
+
if as_json:
|
|
167
|
+
print(json.dumps(tool, indent=2, ensure_ascii=False))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
if not tool:
|
|
171
|
+
print("Herramienta no encontrada.")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if HAS_RICH and console:
|
|
175
|
+
name = tool.get("name", "Desconocido")
|
|
176
|
+
desc = tool.get("description") or tool.get("desc") or ""
|
|
177
|
+
url = tool.get("url") or tool.get("href") or tool.get("repo") or ""
|
|
178
|
+
commands = tool.get("commands") or tool.get("command") or []
|
|
179
|
+
|
|
180
|
+
content = f"[bold green]{name}[/bold green]\n\n{desc}\n\n[bold blue]URL:[/bold blue] {url}"
|
|
181
|
+
if commands:
|
|
182
|
+
if isinstance(commands, list):
|
|
183
|
+
cmds_str = "\n".join([f" $ {c}" for c in commands])
|
|
184
|
+
else:
|
|
185
|
+
cmds_str = f" $ {commands}"
|
|
186
|
+
content += f"\n\n[bold yellow]Comandos recomendados:[/bold yellow]\n[cyan]{cmds_str}[/cyan]"
|
|
187
|
+
|
|
188
|
+
console.print(Panel(content, title="Detalles de Herramienta", border_style="green"))
|
|
189
|
+
else:
|
|
190
|
+
print(f"Nombre: {tool.get('name')}")
|
|
191
|
+
print(f"Descripción: {tool.get('description')}")
|
|
192
|
+
print(f"URL: {tool.get('url')}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def print_cli_tools(tools: List[Dict[str, Any]], as_json: bool = False):
|
|
196
|
+
if as_json:
|
|
197
|
+
print(json.dumps(tools, indent=2, ensure_ascii=False))
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if HAS_RICH and console:
|
|
201
|
+
table = Table(title=f"Herramientas CLI ({len(tools)})", show_header=True, header_style="bold yellow")
|
|
202
|
+
table.add_column("Herramienta CLI", style="bold green")
|
|
203
|
+
table.add_column("Comando", style="cyan")
|
|
204
|
+
table.add_column("Descripción", style="white")
|
|
205
|
+
|
|
206
|
+
for t in tools:
|
|
207
|
+
cmd = t.get("command") or t.get("setup") or "-"
|
|
208
|
+
if isinstance(cmd, list):
|
|
209
|
+
cmd = " | ".join(cmd[:2])
|
|
210
|
+
table.add_row(str(t.get("name") or "-"), str(cmd), str(t.get("description") or "-"))
|
|
211
|
+
console.print(table)
|
|
212
|
+
else:
|
|
213
|
+
for t in tools:
|
|
214
|
+
print(f"[{t.get('name')}] {t.get('command')} - {t.get('description')}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def print_agent_guide():
|
|
218
|
+
guide_md = """
|
|
219
|
+
# LOKTAR CLI - Guía para Agentes de IA
|
|
220
|
+
|
|
221
|
+
Bienvenido a la CLI de **LOKTAR**. 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.
|
|
222
|
+
|
|
223
|
+
## SKILL Completa
|
|
224
|
+
|
|
225
|
+
Para obtener la guía completa optimizada para agentes, ejecuta:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
loktar skill
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Esto imprime la SKILL.md con toda la referencia: comandos, estructura de tablets, flujo de trabajo y ejemplos.
|
|
232
|
+
|
|
233
|
+
## Buenas Prácticas para Agentes
|
|
234
|
+
|
|
235
|
+
1. **Usa el flag `-j` (posicion global o en subcommand):**
|
|
236
|
+
Devuelve datos limpios y estructurados en JSON.
|
|
237
|
+
```bash
|
|
238
|
+
loktar -j search "ocr"
|
|
239
|
+
loktar tablets list -j
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
2. **Flujo de Trabajo Recomendado:**
|
|
243
|
+
- `loktar ping`: Verificar conexión y autenticación.
|
|
244
|
+
- `loktar tablets list`: Ver todos los tablets y categorías disponibles.
|
|
245
|
+
- `loktar search "<termino>"`: Buscar herramientas por palabras clave.
|
|
246
|
+
- `loktar tablets get <id>`: Obtener todas las herramientas de un tablet específico.
|
|
247
|
+
- `loktar tools get "<nombre>"`: Ver detalles y comandos exactos de una herramienta.
|
|
248
|
+
|
|
249
|
+
3. **Autenticación en Agentes:**
|
|
250
|
+
Define la variable de entorno `LOKTAR_API_KEY`:
|
|
251
|
+
```bash
|
|
252
|
+
export LOKTAR_API_KEY="lk_tu_token_aqui"
|
|
253
|
+
```
|
|
254
|
+
"""
|
|
255
|
+
if HAS_RICH and console:
|
|
256
|
+
console.print(Markdown(guide_md))
|
|
257
|
+
else:
|
|
258
|
+
print(guide_md)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loktar-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI and AI agent client for LOKTAR — query 2100+ curated AI tools across 86+ tablets
|
|
5
|
+
Author-email: Loktar Team <info@loktar.cc>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://loktar.cc
|
|
8
|
+
Keywords: cli,ai-tools,agents,mcp,loktar
|
|
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
|
+
# LOKTAR CLI (`loktar`)
|
|
24
|
+
|
|
25
|
+
A super efficient, scriptable command-line interface and AI agent client for **LOKTAR** (https://loktar.cc).
|
|
26
|
+
|
|
27
|
+
Allows both human developers and autonomous AI agents (Claude Code, Antigravity, Aider, Cursor, etc.) to query, search, and inspect 86+ tablets and 2100+ AI tools with minimum token usage and zero prompt overhead.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install --upgrade 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 [loktar.cc](https://loktar.cc).
|
|
45
|
+
|
|
46
|
+
- **For AI Agents & CI/CD:**
|
|
47
|
+
```bash
|
|
48
|
+
export LOKTAR_API_KEY="lk_your_token_here"
|
|
49
|
+
```
|
|
50
|
+
- **Interactive Browser Login (Humans):**
|
|
51
|
+
```bash
|
|
52
|
+
loktar login
|
|
53
|
+
```
|
|
54
|
+
- **Manual Login:**
|
|
55
|
+
```bash
|
|
56
|
+
loktar login --token "lk_your_token_here"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Commands
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Ping service
|
|
63
|
+
loktar ping
|
|
64
|
+
|
|
65
|
+
# Search tools (returns concise table)
|
|
66
|
+
loktar search "ocr"
|
|
67
|
+
|
|
68
|
+
# Search tools in raw JSON (for agents / jq)
|
|
69
|
+
loktar -j search "ocr"
|
|
70
|
+
|
|
71
|
+
# List all tablets
|
|
72
|
+
loktar tablets list
|
|
73
|
+
|
|
74
|
+
# Inspect a specific tablet
|
|
75
|
+
loktar tablets get ocr
|
|
76
|
+
|
|
77
|
+
# Inspect tool details
|
|
78
|
+
loktar tools get "Chandra OCR"
|
|
79
|
+
|
|
80
|
+
# Display CLI agent guide
|
|
81
|
+
loktar guide
|
|
82
|
+
|
|
83
|
+
# Display full agent skill
|
|
84
|
+
loktar skill
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Options
|
|
88
|
+
|
|
89
|
+
- `--json` / `-j`: Output raw JSON without Rich styling.
|
|
90
|
+
- `--url` / `-u`: Override base server URL (default: `https://loktar.cc`).
|
|
91
|
+
- `--token` / `-t`: Override API token.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT License
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
loktar_cli/SKILL.md,sha256=HVZYS9T8wUL43077bJ2z2XfbvWVWGy4q9FA2ptzjjjw,4073
|
|
2
|
+
loktar_cli/__init__.py,sha256=b1smALaNAn5jT0QxKMAwwyqTMmTJCy9_MrzzwWCbRJo,96
|
|
3
|
+
loktar_cli/auth.py,sha256=m52uoh_RFZ78WlTX7p9NHiNmeOCrL6KoJWAM5UX1tZc,5586
|
|
4
|
+
loktar_cli/cli.py,sha256=FY_wZ08u5CxRW1yzTF6Tm8udZi-NzjYq6SVhXYc5k6k,8962
|
|
5
|
+
loktar_cli/client.py,sha256=LXLm3DMqDoq2KgGNGSBCbMIs8_ulJnsVbiMfna7407c,7216
|
|
6
|
+
loktar_cli/config.py,sha256=bjZ3kPEbOPDnEc3gTFEn8xK1B5aGSCeuNy09RYSZi-Q,3388
|
|
7
|
+
loktar_cli/formatters.py,sha256=wlJaitwhYCBDi2TV0cdlK-BlfUow2SWYPYxggRMWxJU,10060
|
|
8
|
+
tests/test_cli.py,sha256=tqwKrO_MhNl_91QBQZ4DRFkyWS-eeuGJiNF9KmcgzIM,486
|
|
9
|
+
loktar_cli-0.1.0.dist-info/METADATA,sha256=NvQ2XHGGgniskvZcDJc4IGgk5p8tB8TyVFL1r9LFHMk,2242
|
|
10
|
+
loktar_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
loktar_cli-0.1.0.dist-info/entry_points.txt,sha256=30M4Yy__FE6moUrSt8wTlubJ0FRazn7YWOHcH28Xv4k,47
|
|
12
|
+
loktar_cli-0.1.0.dist-info/top_level.txt,sha256=xnXcKwn18UjWZ_YUY42duLRYM7mKkMzlRoAx7tk_vYg,17
|
|
13
|
+
loktar_cli-0.1.0.dist-info/RECORD,,
|
tests/test_cli.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from click.testing import CliRunner
|
|
2
|
+
from loktar_cli.cli import cli
|
|
3
|
+
from loktar_cli import __version__
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_cli_version():
|
|
7
|
+
runner = CliRunner()
|
|
8
|
+
result = runner.invoke(cli, ["--version"])
|
|
9
|
+
assert result.exit_code == 0
|
|
10
|
+
assert __version__ in result.output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_cli_guide():
|
|
14
|
+
runner = CliRunner()
|
|
15
|
+
result = runner.invoke(cli, ["guide"])
|
|
16
|
+
assert result.exit_code == 0
|
|
17
|
+
assert "Guía para Agentes de IA" in result.output or "LOKTAR CLI" in result.output
|