Crear una CLI de agentes tipo R
En esta lección vas a construir una versión mínima de R, el proyecto open source de Ramón Guillamón: una CLI local-first para agentes privados con modelos locales, skills, permisos, workflows y memoria persistente.
- Entender la arquitectura real de una CLI de agentes como R.
- Crear una versión mínima en Python con Click, Rich, Ollama y SQLite.
- Añadir skills, permisos, auditoría, workflows YAML y tareas persistentes.
- Evitar el error típico: una CLI que puede hacerlo todo sin límites.
Respuesta rápida para Google, ChatGPT y Claude
Para crear una CLI de agentes IA local tipo R, construye un paquete Python instalable con Click para comandos, Rich para salida legible, Ollama como backend local, un sistema de skills pequeñas, una capa de permisos, auditoría en JSONL, workflows YAML y una cola SQLite para tareas persistentes. La clave no es dar más poder al modelo, sino limitar qué herramientas puede usar y dejar rastro de cada acción.
Qué vamos a copiar de R
No vamos a copiar el repo completo. Vamos a copiar sus decisiones buenas: local-first, permisos por capacidad, skills pequeñas, configuración en YAML, salida auditable, workflows reproducibles y una cola de tareas que sobrevive al cierre de la terminal.
- CLI conversacional: `r "resume este repo"` se convierte en chat directo.
- Backends locales: Ollama, LM Studio o cualquier API compatible con OpenAI en loopback.
- Skills: herramientas estrechas que el agente puede invocar sin recibir acceso total al sistema.
- Permisos: red, archivos, Docker, email o SSH no se activan por accidente.
- Agent OS: agentes con manifiesto, cola, estados, eventos y memoria.
# Probar el R real como referencia git clone https://github.com/raym33/r.git cd r python -m venv .venv source .venv/bin/activate python -m pip install -e ".[dev]" # Modelo local ollama pull qwen2.5:7b ollama serve # Configuracion minima mkdir -p ~/.r-cli cat > ~/.r-cli/config.yaml <<'YAML' llm: backend: ollama model: qwen2.5:7b base_url: http://127.0.0.1:11434/v1 security: local_only: true network_access: false mode: ask YAML r doctor r chat "Resume este repositorio en 5 puntos"
Arquitectura mínima
La versión educativa se llamará `aulafy-r-mini`. Tendrá menos funciones que R, pero las piezas importantes estarán ahí.
aulafy-r-mini/
pyproject.toml
src/aulafy_r/
__init__.py
main.py
config.py
llm.py
permissions.py
agent.py
workflows.py
agent_os.py
skills/
__init__.py
math_skill.py
fs_skill.py
tests/
test_permissions.py
test_workflows.py1. Crea el paquete instalable
Una CLI seria debe instalar un comando real, no depender de ejecutar `python script.py`.
mkdir -p aulafy-r-mini/src/aulafy_r/skills cd aulafy-r-mini python -m venv .venv source .venv/bin/activate cat > pyproject.toml <<'TOML' [project] name = "aulafy-r-mini" version = "0.1.0" description = "CLI local-first educativa para agentes de IA privados" requires-python = ">=3.10" dependencies = [ "click>=8.0.0", "rich>=13.0.0", "httpx>=0.25.0", "pydantic>=2.0.0", "pyyaml>=6.0.0", ] [project.scripts] ar = "aulafy_r.main:cli" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" TOML touch src/aulafy_r/__init__.py touch src/aulafy_r/skills/__init__.py python -m pip install -e .
2. Configuración local-first
R rechaza por defecto endpoints que no sean locales. Esa decisión es clave: si tu CLI manda documentos a cualquier URL sin avisar, no es local-first, solo es una terminal bonita.
# src/aulafy_r/config.py
from pathlib import Path
from pydantic import BaseModel
import yaml
class LLMConfig(BaseModel):
model: str = "qwen2.5:7b"
base_url: str = "http://127.0.0.1:11434/v1"
class SecurityConfig(BaseModel):
local_only: bool = True
network_access: bool = False
mode: str = "ask"
class Config(BaseModel):
llm: LLMConfig = LLMConfig()
security: SecurityConfig = SecurityConfig()
home_dir: str = "~/.aulafy-r-mini"
@classmethod
def load(cls) -> "Config":
path = Path("~/.aulafy-r-mini/config.yaml").expanduser()
if not path.exists():
return cls()
return cls(**yaml.safe_load(path.read_text()) or {})
def is_loopback(url: str) -> bool:
return url.startswith("http://127.0.0.1") or url.startswith("http://localhost")
def validate_local_llm(config: Config) -> None:
if config.security.local_only and not is_loopback(config.llm.base_url):
raise RuntimeError("Endpoint LLM no local. Usa 127.0.0.1 o localhost.")3. Cliente LLM compatible con Ollama
Ollama expone una API compatible con OpenAI en `/v1/chat/completions`. Eso permite cambiar de proveedor sin reescribir el agente.
# src/aulafy_r/llm.py
import httpx
from .config import Config, validate_local_llm
def chat(config: Config, messages: list[dict[str, str]]) -> str:
validate_local_llm(config)
url = config.llm.base_url.rstrip("/") + "/chat/completions"
payload = {
"model": config.llm.model,
"messages": messages,
"temperature": 0.2,
}
response = httpx.post(url, json=payload, timeout=120)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]4. CLI con chat directo
R usa un grupo de Click que interpreta comandos desconocidos como mensajes. Así `r "explica esto"` funciona sin escribir `chat`. Esta versión mínima replica ese patrón.
# src/aulafy_r/main.py
import click
from rich.console import Console
from rich.panel import Panel
from .config import Config
from .llm import chat as llm_chat
console = Console()
class DirectChatGroup(click.Group):
def resolve_command(self, ctx, args):
try:
return super().resolve_command(ctx, args)
except click.UsageError:
if not args or args[0].startswith("-"):
raise
return "chat", self.get_command(ctx, "chat"), args
@click.group(cls=DirectChatGroup, invoke_without_command=True)
@click.pass_context
def cli(ctx):
if ctx.invoked_subcommand is None:
console.print(ctx.get_help())
@cli.command()
@click.argument("message", nargs=-1, required=True)
def chat(message):
config = Config.load()
text = " ".join(message)
answer = llm_chat(config, [{"role": "user", "content": text}])
console.print(Panel(answer, title="Aulafy R Mini"))
@cli.command()
def doctor():
config = Config.load()
console.print({
"model": config.llm.model,
"base_url": config.llm.base_url,
"local_only": config.security.local_only,
})
if __name__ == "__main__":
cli()ar doctor ar chat "Dime una receta de RAG local" ar "Ahora resume este proyecto como si fuera para una pyme"
5. Skills pequeñas, no superpoderes
Una skill es una herramienta con contrato. No le des al modelo una función `run_shell(command)`. Dale `math.calculate`, `fs.read_text` o `git.status`, cada una con límites claros.
# src/aulafy_r/skills/math_skill.py
import ast
import operator as op
OPS = {
ast.Add: op.add,
ast.Sub: op.sub,
ast.Mult: op.mul,
ast.Div: op.truediv,
ast.Pow: op.pow,
ast.USub: op.neg,
}
def calculate(expression: str) -> float:
def eval_node(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](eval_node(node.left), eval_node(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in OPS:
return OPS[type(node.op)](eval_node(node.operand))
raise ValueError("Expresion no permitida")
tree = ast.parse(expression, mode="eval")
return eval_node(tree.body)# src/aulafy_r/skills/__init__.py
from .math_skill import calculate
TOOLS = {
"math.calculate": calculate,
}
def run_tool(name: str, **kwargs):
if name not in TOOLS:
raise KeyError(f"Tool no registrada: {name}")
return TOOLS[name](**kwargs)6. Permisos y auditoría
La diferencia entre una demo y una herramienta útil está aquí. Cada llamada debe clasificarse, autorizarse y quedar registrada.
# src/aulafy_r/permissions.py
from dataclasses import dataclass, asdict
from pathlib import Path
import json
import time
import uuid
RISK = {
"math": "low",
"fs": "medium",
"git": "high",
"docker": "critical",
"email": "critical",
}
@dataclass
class PermissionRequest:
tool: str
risk: str
arguments: dict
trace_id: str
def classify(tool: str) -> str:
skill = tool.split(".", 1)[0]
return RISK.get(skill, "high")
def authorize(tool: str, arguments: dict, auto_approve: bool = False) -> PermissionRequest:
request = PermissionRequest(
tool=tool,
risk=classify(tool),
arguments=arguments,
trace_id=str(uuid.uuid4()),
)
if request.risk in {"high", "critical"} and not auto_approve:
raise PermissionError(f"Permiso requerido para {tool} ({request.risk})")
audit(request, "allowed")
return request
def audit(request: PermissionRequest, outcome: str) -> None:
home = Path("~/.aulafy-r-mini").expanduser()
home.mkdir(parents=True, exist_ok=True)
record = {"ts": time.time(), "outcome": outcome, **asdict(request)}
with (home / "audit.jsonl").open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")7. Workflows YAML reproducibles
R no depende solo de “preguntar al agente”. También permite workflows declarativos: pasos, dependencias, variables, dry-run y reintentos. Esto es lo que convierte una tarea en una cápsula repetible.
# workflow.yaml
version: 1
name: informe-calculo
steps:
- id: base
uses: math.calculate
with:
expression: "6 * 7"
- id: doble
uses: math.calculate
depends_on: [base]
with:
expression: "42 * 2"# src/aulafy_r/workflows.py
import yaml
from .permissions import authorize
from .skills import run_tool
def run_workflow(path: str, dry_run: bool = False):
raw = yaml.safe_load(open(path, encoding="utf-8"))
results = {}
for step in raw["steps"]:
for dep in step.get("depends_on", []):
if dep not in results:
raise RuntimeError(f"Dependencia pendiente: {dep}")
tool = step["uses"]
args = step.get("with", {})
if dry_run:
results[step["id"]] = {"dry_run": True, "tool": tool, "args": args}
continue
authorize(tool, args, auto_approve=True)
results[step["id"]] = run_tool(tool, **args)
return results8. Agent OS mínimo con SQLite
La cola es lo que separa un comando puntual de una capa operativa: puedes crear, pausar, reintentar y auditar tareas.
# src/aulafy_r/agent_os.py
from pathlib import Path
import sqlite3
import time
import uuid
DB = Path("~/.aulafy-r-mini/agent-os.db").expanduser()
def connect():
DB.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB)
con.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
agent TEXT NOT NULL,
input TEXT NOT NULL,
status TEXT NOT NULL,
created_at REAL NOT NULL
)
""")
return con
def submit(agent: str, text: str) -> str:
task_id = str(uuid.uuid4())
with connect() as con:
con.execute(
"INSERT INTO tasks VALUES (?, ?, ?, ?, ?)",
(task_id, agent, text, "queued", time.time()),
)
return task_id
def list_tasks():
with connect() as con:
return con.execute(
"SELECT id, agent, status, input FROM tasks ORDER BY created_at DESC"
).fetchall()9. Manifiesto de agente
Un agente no debería ser “el modelo con todo abierto”. Debe tener identidad, instrucciones, skills, rutas y política de red.
# researcher.yaml name: private-researcher description: Analiza documentos dentro de un proyecto concreto system_prompt: | Eres un investigador local. Cita evidencia y no inventes datos. skills: [fs, math] network_access: false filesystem_roots: - ./documents
Qué añadir después
- Streaming: mostrar tokens mientras llegan.
- Tool calling real: pasar schemas al modelo y ejecutar tools validadas.
- API local: FastAPI en `127.0.0.1` para una UI tipo Control Center.
- Memoria: SQLite para sesión corta y Qdrant/GBrain para memoria semántica.
- MCP: cargar servidores manualmente, con allowlist y sin auto-loading.
- Distribución: `pyproject.toml`, tests, GitHub Actions y releases.
Preguntas frecuentes
Qué es una CLI de agentes IA local tipo R
Es una herramienta de terminal que ejecuta agentes de IA en tu propio ordenador, usando modelos locales, skills limitadas, permisos, auditoría, workflows y memoria persistente.
Necesito usar el repo raym33/r para seguir el tutorial
No. La lección muestra cómo probar raym33/r como referencia y después construir una versión mínima educativa desde cero.
Qué tecnologías usa la versión mínima
Python, Click, Rich, Ollama, YAML, SQLite, skills propias, control de permisos y workflows reproducibles.