Saltar al contenido
Aula en la nube
Proyecto 2 · Sesión 2 · Semana 2-6 nov

Acciones predefinidas: acciones.json y el motor

Esta semana está la idea central del proyecto: tú decides qué hace tu herramienta escribiendo acciones.json; el agente programa el motor que lo interpreta. Y a la vez das el segundo paso con OpenCode: le enseñas tu esquema con una skill, te creas un comando propio para añadir acciones y un subagente revisor que lee tu código sin poder tocarlo. Partes de lo que dejaste en la Sesión 1: el comando tareas con --help, version y listar, el repo con su .gitignore, tu opencode.json de permisos y tu AGENTS.md.

🗓 Semana 2 · 2-6 nov🧩 acciones.json → config → Motor → Accion🤖 skill · comando · subagente🛡 Excepciones propias📝 Primer ADR

Producto al acabar la semana

  • 3 acciones propias en acciones.json funcionando, siempre primero con --simular.
  • Las órdenes tareas validar, tareas acciones y tareas ejecutar.
  • Excepciones propias: TareasError, ConfigError, AccionNoValida.
  • docs/adr/0001-configuracion-externa.md escrito con tus razones.
  • En el repo: la skill .opencode/skills/acciones-json/, el comando /nueva-accion y el subagente revisor.
  • ≥ 10 commits tuyos acumulados en GitHub (los de la Sesión 1 cuentan).

Semipresencial: esto es exactamente lo que se comprueba el viernes 6 de noviembre, al cerrar la quincena 3: 3 acciones propias funcionando, el ADR y commits reales en tu repo. En casa trabajas también con OpenCode (puedes abrirlo en la terminal integrada de VS Code); cómo conectarte desde casa a los modelos del centro lo explica el profesor en la tutoría colectiva de arranque.

¿Dudas con OpenCode (atajos, permisos, sesiones)? Tienes la guía de OpenCode del curso.

Por qué configuración externa y no código

Piensa en el mando de la tele. El fabricante programa una vez lo que hace cada botón (el motor), pero qué canal está en el 1 lo decides tú en el menú, sin abrir el aparato. Si para cambiar el canal del botón 1 tuvieras que soldar algo dentro, nadie lo haría. Con tu herramienta pasa igual: si cada acción es una función escrita en Python, cambiar «mueve los PDF» por «mueve los PDF y los EPUB» exige tocar código, probarlo y reinstalar. Si vive en un fichero de configuración, cambias una línea.

No es un invento del curso: lo usas todos los días sin darte cuenta.

FicheroQuién lo interpretaQué decides sin tocar el programa
.gitignoregitQué ficheros no se suben (lo escribiste en la Sesión 1).
package.jsonnpm / NodeDependencias y órdenes como npm run dev.
docker-compose.ymlDockerQué servicios arrancan, en qué puertos, con qué variables.
settings.json de VS CodeVS CodeTema, tamaño de letra, formateo al guardar…
pyproject.tomluv / pipNombre del paquete, versión y el comando tareas.
opencode.json · AGENTS.md · .opencode/OpenCodeQué puede hacer tu agente, qué normas sigue y qué sabe de tu proyecto.
acciones.jsonTU motorQué hace tu herramienta con tus ficheros.
La misma idea, dos veces. Igual que tu CLI lee acciones.json, OpenCode lee opencode.json, AGENTS.md y la carpeta .opencode/. Nadie recompila OpenCode para adaptarlo a tu proyecto: tú configuras a tu agente igual que tu usuario configura tu herramienta. Esta semana harás las dos cosas: el acciones.json de tu CLI y tres ficheros nuevos en .opencode/.

El reparto de papeles de tu CLI queda así: el JSON dice qué; config.py comprueba que el JSON tiene sentido antes de tocar un solo fichero; el Motor elige los ficheros que cumplen el filtro; cada Accion sabe hacer una cosa; y la CLI pinta el resultado.

acciones.jsonlo escribes túconfig.pycarga y validaMotorelige los ficherosAccion.ejecutar()sobre los ficherosSalida Richtabla qué → dónde--simularmisma tabla, 0 cambiosConfigErrorsale con código 3si algo fallasolo llega aquí un JSON válido

← desliza el diagrama →

El método de cada paso (el mismo de la Sesión 1)

  1. Tú escribes la intención: el esquema, la acción, el criterio de aceptación.
  2. Agente plan (Tab): «explícame cómo lo harías y qué ficheros tocarías». Lees el plan y lo corriges.
  3. Agente build (Tab): «hazlo». Apruebas cada edición y cada orden leyendo el diff.
  4. Auditas: lees el código, pruebas en ~/pruebas-tareas con --simular y con casos feos. Si algo sale mal, /undo o le explicas el fallo.
  5. Commit pequeño hecho por ti y el prompt clave a docs/prompts.md (o /export de la sesión).
  6. Sesión nueva por tarea (/new); /compact si la conversación se alarga.

1Diseña tu esquema (lo haces TÚ, sin IA)

Esta parte es la que te pertenece: el esquema es la especificación de tu herramienta. Si se lo pides a la IA, tendrás la herramienta de otra persona. OpenCode, cerrado. Crea acciones.json en la raíz del repo partiendo de este ejemplo:

JSON
{
"version": 1,
"acciones": [
{
"nombre": "ordenar-descargas",
"descripcion": "Mueve los PDF y documentos de Descargas a Documentos/PDF",
"tipo": "mover",
"origen": "~/Descargas",
"filtro": { "extensiones": [".pdf", ".docx"] },
"destino": "~/Documentos/PDF"
},
{
"nombre": "renombrar-fotos",
"descripcion": "Renombra fotos con su fecha: 2026-10-26_001.jpg",
"tipo": "renombrar",
"filtro": { "extensiones": [".jpg", ".jpeg", ".png"] },
"plantilla": "{fecha:%Y-%m-%d}_{n:03}{ext}"
},
{
"nombre": "limpiar-temporales",
"descripcion": "Borra .tmp con más de 30 días",
"tipo": "borrar",
"filtro": { "patron": ".*\\.tmp$", "mas_antiguo_que_dias": 30 },
"confirmar": true
}
]
}

Campo a campo

CampoObligatorioTipoEjemploQué hace
versionsí (raíz)entero1Versión del esquema. Si mañana cambias el formato, subes a 2 y el programa sabe qué está leyendo.
accionessí (raíz)lista[ … ]La lista de acciones. Vacía = error de configuración.
nombresítexto sin espaciosordenar-descargasLo que escribes en tareas ejecutar NOMBRE. Único: no puede haber dos iguales.
descripcionsítextoMueve los PDF…Lo que enseña tareas acciones. Escríbela para alguien que no eres tú.
tiposíuno de la listamoverQué clase de acción es. El motor elige la clase Python a partir de este campo.
origennoruta~/DescargasCarpeta por defecto si no pasas CARPETA en la orden. ~ se expande a tu carpeta personal en Linux, macOS y Windows.
filtronoobjeto{ "extensiones": [".pdf"] }Qué ficheros de la carpeta se tocan. Sin filtro = todos (¡cuidado con borrar!).
destinomover / copiarruta~/Documentos/PDFA dónde van. Si no existe, se crea.
plantillarenombrartexto con {huecos}{fecha:%Y-%m-%d}_{n:03}{ext}El nombre nuevo de cada fichero.
confirmarno (borrar: sí)true / falsetruePide confirmación antes de actuar salvo que pases --si.

Tipos de acción

tipoQué le pasa al ficheroPara qué en tu vida real
moverorigen → destinoOrdenar Descargas, sacar las capturas del escritorio.
copiarorigen → destino (el original se queda)Copia de seguridad de tus apuntes a un USB o a una carpeta sincronizada.
renombrarmismo sitio, nombre nuevoFotos con fecha, apuntes numerados (tema-01, tema-02…).
borrardesaparece (con confirmación)Temporales, instaladores .exe/.deb viejos, ficheros vacíos.
… con IA (semana 3)lo decide un modelo del centroClasificar por contenido, poner nombre a partir del texto de un PDF, resumir.

Filtros: qué ficheros se tocan

Dentro de filtro. Si pones varios, el fichero tiene que cumplirlos todos.

extensiones[".pdf", ".docx"]Solo esas extensiones. Compáralas en minúsculas: FOTO.JPG también es .jpg.
patron"^captura.*\\.png$"Expresión regular sobre el nombre. En JSON la barra invertida se escribe doble.
mas_antiguo_que_dias30Solo ficheros cuya fecha de modificación tiene más de N días.
mayor_que_mb100Solo ficheros de más de N MB (vídeos olvidados, ISOs…).

Plantillas de nombre

Para renombrar. Son huecos entre llaves que el motor rellena con el formato de Python (str.format): lo de detrás de los dos puntos es el formato.

{fecha:%Y-%m-%d}2026-11-03Fecha de modificación del fichero con el formato que quieras (%d/%m no: la barra no vale en nombres).
{n:03}001, 002…Contador con ceros a la izquierda. {n} sin formato da 1, 2, 3.
{ext}.jpgLa extensión original, con punto, en minúsculas.
{nombre}IMG_2041El nombre original sin extensión, por si quieres conservarlo: {fecha:%Y%m%d}-{nombre}{ext}.
Reto de la semana: al final tendrás 3 acciones para TU vida real (las del ejemplo no cuentan). La primera la escribes ahora, a mano; la 2.ª y la 3.ª las añadirás en el paso 5 con tu comando /nueva-accion. Ideas, de menos a más difícil:
  1. Fácil: mover las capturas de pantalla del escritorio a ~/Imágenes/Capturas (filtro por extensión).
  2. Media: copiar los apuntes de DAM modificados hace menos de 7 días a una carpeta de copia, o borrar instaladores .exe/.deb de más de 60 días.
  3. Difícil: renombrar las fotos del móvil a {fecha:%Y-%m-%d}_{n:03}{ext} usando un patron regex que solo coja las que empiezan por IMG_ o PXL_.

Después, añade al README una sección «Esquema de acciones.json» con la tabla de campos (la tuya, no la de esta página si has cambiado algo). Si el esquema no está escrito, el agente se lo inventa: en el paso siguiente se lo enseñas.

2Enseña el esquema a tu agente: tu primera skill

Si en cada prompt tuvieras que pegar tu esquema entero, acabarías harto y, peor, una vez se te olvidaría. Para eso existen las skills: un fichero Markdown con conocimiento concreto (cómo es tu acciones.json y qué reglas sigue el código que lo lee) que el agente carga cuando lo necesita.

Antes de crearla, el mapa de lo que puedes poner en tu repo para personalizar a OpenCode:

PiezaDónde viveQuién la activaPara qué
AGENTS.mdraíz del repoSiempre: entra en todas las conversacionesNormas generales del proyecto (stack, órdenes, «no hagas push»).
Skill.opencode/skills/NOMBRE/SKILL.mdEl agente, bajo demanda, cuando la description encaja con la tareaConocimiento concreto que no hace falta siempre: tu esquema de acciones.json.
Comando.opencode/commands/NOMBRE.mdTú, escribiendo /NOMBRE argumentosUn prompt que repites a menudo, con sus pasos fijos.
Subagente.opencode/agents/NOMBRE.mdTú con @NOMBRE (o el agente principal, si lo necesita)Otro agente con su propio papel y sus propios permisos: un revisor que no puede editar.
Text
cli-tareas/
├── opencode.json # permisos del proyecto (Sesión 1)
├── AGENTS.md # normas que el agente lee SIEMPRE (Sesión 1)
├── acciones.json
├── .opencode/
│ ├── skills/
│ │ └── acciones-json/
│ │ └── SKILL.md # paso 2: lo que el agente sabe de tu esquema
│ ├── commands/
│ │ └── nueva-accion.md # paso 5: tu orden /nueva-accion
│ └── agents/
│ └── revisor.md # paso 6: tu subagente @revisor
└── src/tareas/ …

Crea la carpeta .opencode/skills/acciones-json/ y dentro el fichero SKILL.md (en mayúsculas) con este contenido. Adáptalo a tu esquema si has cambiado campos:

Markdown
---
name: acciones-json
description: Reglas del fichero acciones.json de la CLI tareas (campos, tipos, filtros, plantillas y validación). Úsala siempre que crees o cambies acciones o el código que las lee (config.py, acciones.py, motor.py).
---
# Esquema de acciones.json
- Raíz: {"version": 1, "acciones": [...]}. Cada acción: nombre (único, minúsculas-con-guiones), descripcion, tipo.
- Tipos: mover y copiar (requieren destino), renombrar (requiere plantilla), borrar (siempre pide confirmación salvo --si).
- filtro opcional: extensiones (lista, en minúsculas), patron (regex válida), mas_antiguo_que_dias, mayor_que_mb.
- plantilla: huecos {fecha:%Y-%m-%d}, {n:03}, {ext}, {nombre}.
## Reglas al programar
- Valida el JSON en config.py ANTES de ejecutar; errores con AccionNoValida(accion, campo, motivo).
- pathlib y shutil; encoding="utf-8"; nunca shell=True; nunca sobrescribir (destino_libre).
- Toda acción admite --simular y devuelve list[Resultado].
- Si añades un campo o un tipo nuevo, documéntalo en el README y en esta skill.
  • Lo de arriba entre --- es el frontmatter. name tiene que ser igual que el nombre de la carpeta (acciones-json), en minúsculas y con guiones.
  • description es lo más importante: el agente solo ve el nombre y la descripción de cada skill, y con eso decide si la carga. Por eso dice qué es y, sobre todo, cuándo usarla («siempre que crees o cambies acciones o el código que las lee»). Sin descripción, la skill no le llega al modelo.
  • Cuando la tarea encaja, el agente llama a su herramienta skill y entonces sí lee el fichero entero. Así no gasta contexto con tu esquema cuando le pides otra cosa.
  • La última regla es para el agente y para ti: si el esquema cambia, la skill cambia. Una skill desactualizada enseña mal.
¿Y por qué no meterlo en AGENTS.md? AGENTS.md entra siempre, en cada conversación: ahí van las normas generales (stack, pathlib, «no hagas push»). La skill entra solo cuando hace falta: ahí va el conocimiento detallado de una parte del proyecto. Con los modelos del centro el contexto cuesta, así que AGENTS.md corto y el detalle en skills.

Compruébalo. OpenCode no recarga la configuración en caliente: sal (ctrl+c) y vuelve a abrirlo en la carpeta del repo. Escribe /skills: tiene que aparecer acciones-json. Desde la terminal también puedes verlo con opencode debug skill.

Bash
git add .opencode/skills/acciones-json/SKILL.md
git commit -m "Skill acciones-json: el agente carga las reglas de mi esquema"

3Valida antes de ejecutar, con el agente

Un programa que borra ficheros no puede fiarse de un JSON escrito a mano. Primero se comprueba todo el fichero y solo si está bien se toca el disco. Y cuando algo falla, el mensaje tiene que decir qué acción y qué campo: «Error en la configuración» no sirve; «Acción «renombrar-fotos», campo «plantilla»: es obligatorio en acciones de tipo renombrar» te lo arregla en 10 segundos.

Abre una sesión nueva (/new) y pasa al agente plan con Tab. Fíjate en lo corto que es el prompt: @acciones.json adjunta tu fichero y la skill pone el resto.

Carga la skill acciones-json y lee @acciones.json.
Quiero validar acciones.json ANTES de ejecutar nada:
- src/tareas/errores.py con TareasError, ConfigError y AccionNoValida;
- src/tareas/config.py con cargar(ruta) -> list[dict];
- la orden «tareas validar» en src/tareas/cli.py.
No toques nada todavía: dime qué ficheros crearías o cambiarías, qué clases
y funciones tendrán y qué casos de error vas a detectar.

Lee el plan. ¿Ha entendido tu esquema? ¿Se olvida de algún caso (nombre repetido, regex rota)? Corrígelo ahí mismo. Cuando el plan te convenza, Tab para pasar a build:

Hazlo según el plan. Criterio de aceptación:
- TareasError (codigo_salida = 1), ConfigError (codigo_salida = 3) y
  AccionNoValida(accion, campo, motivo) → «Acción «X», campo «Y»: motivo».
- JSON roto → ConfigError con línea y columna.
- Cada mensaje dice QUÉ acción y QUÉ campo fallan, en español.
- «tareas validar»: «✔ N acciones válidas» en verde, o el error en rojo
  SIN traceback y saliendo con e.codigo_salida.
Solo esos tres ficheros, sin dependencias nuevas.
Termina ejecutando: uv run tareas validar

Con tu opencode.json de la Sesión 1, cada edición te pide permiso: lee el diff antes de aceptar. uv run tareas validar sí lo puede ejecutar solo, porque está en tu lista de órdenes permitidas.

Para auditar, esto es lo que deberías obtener. Las excepciones heredan unas de otras: la CLI capturaTareasError una sola vez y cada error trae su código de salida.

Python
# src/tareas/errores.py
class TareasError(Exception):
"""Base de todos los errores previsibles de la herramienta."""
codigo_salida = 1
​
​
class ConfigError(TareasError):
"""acciones.json no existe, no es JSON o no cumple el esquema."""
codigo_salida = 3
​
​
class AccionNoValida(ConfigError):
"""Una acción concreta tiene un campo mal: dice cuál y por qué."""
​
def __init__(self, accion: str, campo: str, motivo: str):
super().__init__(f"Acción «{accion}», campo «{campo}»: {motivo}")
self.accion = accion
self.campo = campo

Y así es, resumido, config.py. No lo copies: es la referencia para comparar con lo que te haya hecho el agente.

Python
# src/tareas/config.py (resumido)
import json
import re
from pathlib import Path
​
from .errores import AccionNoValida, ConfigError
​
TIPOS = {"mover", "copiar", "renombrar", "borrar"}
OBLIGATORIOS = ["nombre", "descripcion", "tipo"]
POR_TIPO = {"mover": "destino", "copiar": "destino", "renombrar": "plantilla"}
​
​
def cargar(ruta: Path) -> list[dict]:
ruta = ruta.expanduser()
if not ruta.is_file():
raise ConfigError(f"No encuentro {ruta}. Crea acciones.json o usa --config RUTA")
try:
datos = json.loads(ruta.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise ConfigError(f"{ruta.name} no es JSON válido: línea {e.lineno}, columna {e.colno} ({e.msg})")
​
acciones = datos.get("acciones") if isinstance(datos, dict) else None
if not isinstance(acciones, list) or not acciones:
raise ConfigError("Falta la lista «acciones» o está vacía")
​
vistos = set()
for i, a in enumerate(acciones, start=1):
nombre = a.get("nombre", f"nº {i}")
for campo in OBLIGATORIOS:
if not a.get(campo):
raise AccionNoValida(nombre, campo, "es obligatorio")
if nombre in vistos:
raise AccionNoValida(nombre, "nombre", "está repetido")
vistos.add(nombre)
if a["tipo"] not in TIPOS:
raise AccionNoValida(nombre, "tipo", f"«{a['tipo']}» no existe; usa uno de: {', '.join(sorted(TIPOS))}")
extra = POR_TIPO.get(a["tipo"])
if extra and not a.get(extra):
raise AccionNoValida(nombre, extra, f"es obligatorio en acciones de tipo {a['tipo']}")
patron = a.get("filtro", {}).get("patron")
if patron is not None:
try:
re.compile(patron)
except re.error as e:
raise AccionNoValida(nombre, "filtro.patron", f"expresión regular rota: {e}")
return acciones

Prueba la orden y su código de salida (en tu terminal, o desde OpenCode con ! delante):

Bash
uv run tareas validar
echo $? # Linux/macOS: 0 si es válido, 3 si no
$LASTEXITCODE # lo mismo en PowerShell

Casos feos que pruebas TÚ

Haz una copia (pruebas/roto.json), estropéala a propósito de cada una de estas formas y ejecuta uv run tareas --config pruebas/roto.json validar. Ninguno puede acabar en traceback. Si alguno falla, explícaselo al agente con el mensaje que has visto, no con «no va».

CasoCómo provocarloLo que debe decir
Coma de másUna coma tras la última acción: … } , ]No es JSON válido: línea X, columna Y
Tipo inexistente"tipo": "mober"Acción «…», campo «tipo»: «mober» no existe; usa uno de: borrar, copiar, mover, renombrar
Nombre repetidoDos acciones con "nombre": "ordenar-descargas"Acción «ordenar-descargas», campo «nombre»: está repetido
Regex rota"patron": "*.tmp" (el * no puede ir el primero)Acción «…», campo «filtro.patron»: expresión regular rota
Extra: fichero que no existe--config nada.jsonNo encuentro nada.json…

4El motor y las acciones en clases, con el agente

Aquí aparecen las clases de verdad. Una clase base Accion define lo que toda acción sabe hacer (ejecutar) y cada subclase (Mover, Copiar, Renombrar,Borrar) solo implementa lo suyo en aplicar. El método estático Accion.desde_config(dict) es una fábrica: recibe un diccionario del JSON y devuelve el objeto de la clase que toca. Gracias a eso, el motor no tiene ni un if tipo == …: añadir un tipo nuevo es escribir una subclase más.

El Motor recibe la carpeta de la orden (o el origen de la acción), selecciona los ficheros que cumplen el filtro y se los pasa a la acción, que devuelve un informe: una lista deResultado (qué fichero, a qué destino, en qué estado).

Sesión nueva (/new). Primero en plan: «Carga la skill acciones-json y explícame cómo harías acciones.py y motor.py: clases, métodos y cómo garantizas que --simular no toca el disco». Cuando el plan te cuadre, en build:

Carga la skill acciones-json. Crea src/tareas/acciones.py y src/tareas/motor.py:
1. acciones.py: dataclass Resultado(origen, destino, estado: hecho | simulado | saltado);
   clase base abstracta Accion con ejecutar(ficheros, simular) y aplicar(fichero, n, simular)
   abstracto; fábrica Accion.desde_config(dict) con un registro de subclases (nada de
   if/elif por tipo); subclases Mover, Copiar, Renombrar y Borrar (destructiva = True).
2. motor.py: clase Motor(acciones) con seleccionar(carpeta, filtro) y
   ejecutar(nombre, carpeta, simular) -> list[Resultado]. Sin carpeta, usa "origen".
   Acción o carpeta inexistente → TareasError con mensaje humano.
Con simular=True no se toca NADA del disco (ni mkdir). Nunca sobrescribir: sufijo « (1)».
No toques cli.py y NO ejecutes ninguna acción: las pruebo yo.
Al terminar, dime qué pasa si ejecuto dos veces seguidas la misma acción de mover y por qué.
Reglas multiplataforma y de seguridad que auditas tú (están en tu AGENTS.md y en la skill, pero que el agente las conozca no significa que las cumpla):
  • pathlib.Path siempre: ninguna ruta montada con + "/" + ni con \.
  • Path.expanduser() para ~; encoding="utf-8" en todo open() o read_text().
  • shutil.move y shutil.copy2; nada de os.system, subprocess ni shell=True.
  • Nombres con tildes y espacios funcionan.
  • Con simular=True no se llama a mkdir, move, rename ni unlink.
  • Nunca se sobrescribe: si el destino existe, sufijo « (1)», « (2)»…
Cada «esto no lo cumple» es un error que le pides corregir y un buen párrafo para docs/prompts.md.

Código de referencia resumido para auditar:

Python
# src/tareas/acciones.py (resumido)
from __future__ import annotations
​
import shutil
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
​
from .errores import AccionNoValida
​
​
@dataclass
class Resultado:
origen: Path
destino: Path | None
estado: str # "hecho", "simulado" o "saltado"
​
​
def destino_libre(destino: Path) -> Path:
"""Nunca sobrescribe: si existe, prueba 'nombre (1).ext', 'nombre (2).ext'..."""
libre, n = destino, 1
while libre.exists():
libre = destino.with_name(f"{destino.stem} ({n}){destino.suffix}")
n += 1
return libre
​
​
class Accion(ABC):
tipo = ""
destructiva = False
_registro: dict[str, type[Accion]] = {}
​
def __init__(self, config: dict):
self.nombre = config["nombre"]
self.descripcion = config["descripcion"]
self.config = config
​
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Accion._registro[cls.tipo] = cls # cada subclase se apunta sola
​
@staticmethod
def desde_config(config: dict) -> Accion:
"""Fábrica: del diccionario del JSON a la clase que toca."""
clase = Accion._registro.get(config["tipo"])
if clase is None:
raise AccionNoValida(config["nombre"], "tipo", "no hay ninguna clase para ese tipo")
return clase(config)
​
def ejecutar(self, ficheros: list[Path], simular: bool) -> list[Resultado]:
return [self.aplicar(f, n, simular) for n, f in enumerate(ficheros, start=1)]
​
@abstractmethod
def aplicar(self, fichero: Path, n: int, simular: bool) -> Resultado: ...
​
​
class Mover(Accion):
tipo = "mover"
​
def aplicar(self, fichero, n, simular):
carpeta = Path(self.config["destino"]).expanduser()
destino = destino_libre(carpeta / fichero.name)
if not simular:
carpeta.mkdir(parents=True, exist_ok=True)
shutil.move(fichero, destino)
return Resultado(fichero, destino, "simulado" if simular else "hecho")
​
​
class Renombrar(Accion):
tipo = "renombrar"
​
def aplicar(self, fichero, n, simular):
fecha = datetime.fromtimestamp(fichero.stat().st_mtime)
nuevo = self.config["plantilla"].format(
fecha=fecha, n=n, ext=fichero.suffix.lower(), nombre=fichero.stem
)
if nuevo == fichero.name:
return Resultado(fichero, None, "saltado")
destino = destino_libre(fichero.with_name(nuevo))
if not simular:
fichero.rename(destino)
return Resultado(fichero, destino, "simulado" if simular else "hecho")
​
​
class Borrar(Accion):
tipo = "borrar"
destructiva = True
​
def aplicar(self, fichero, n, simular):
if not simular:
fichero.unlink()
return Resultado(fichero, None, "simulado" if simular else "hecho")
​
# Copiar es igual que Mover pero con shutil.copy2 (conserva la fecha).
Python
# src/tareas/motor.py (resumido)
import re
import time
from pathlib import Path
​
from .acciones import Accion, Resultado
from .errores import TareasError
​
​
class Motor:
def __init__(self, acciones: list[dict]):
self.acciones = {a["nombre"]: Accion.desde_config(a) for a in acciones}
​
@staticmethod
def seleccionar(carpeta: Path, filtro: dict) -> list[Path]:
exts = {e.lower() for e in filtro.get("extensiones", [])}
patron = re.compile(filtro["patron"]) if "patron" in filtro else None
dias = filtro.get("mas_antiguo_que_dias")
mb = filtro.get("mayor_que_mb")
ahora = time.time()
elegidos = []
for f in sorted(carpeta.iterdir()):
if not f.is_file():
continue
if exts and f.suffix.lower() not in exts:
continue
if patron and not patron.search(f.name):
continue
if dias and ahora - f.stat().st_mtime < dias * 86400:
continue
if mb and f.stat().st_size < mb * 1024 * 1024:
continue
elegidos.append(f)
return elegidos
​
def ejecutar(self, nombre: str, carpeta: Path | None, simular: bool) -> list[Resultado]:
accion = self.acciones.get(nombre)
if accion is None:
raise TareasError(f"No hay ninguna acción «{nombre}». Mira la lista con: tareas acciones")
carpeta = (carpeta or Path(accion.config.get("origen", "."))).expanduser()
if not carpeta.is_dir():
raise TareasError(f"La carpeta {carpeta} no existe")
ficheros = self.seleccionar(carpeta, accion.config.get("filtro", {}))
return accion.ejecutar(ficheros, simular)

La interfaz: acciones, ejecutar, --simular, --si y --config

La CLI es la capa fina que une todo. Tres piezas nuevas:

  • tareas acciones: tabla con nombre, tipo y descripción de lo que hay en acciones.json.
  • tareas ejecutar NOMBRE [CARPETA] --simular --si: la orden que hace el trabajo.
  • --config RUTA: opción global (va antes de la orden: tareas --config otra.json acciones). Por defecto, ./acciones.json.
Código de salidaSignifica
0Todo bien.
1Fallo al ejecutar (carpeta que no existe, acción desconocida, permiso denegado…).
2Error de uso: falta un argumento o una opción no existe (lo pone Typer solo).
3Configuración inválida: acciones.json roto o con campos mal.

Sesión nueva, plan y luego build con:

Amplía SOLO @src/tareas/cli.py (Typer + Rich). Ya existen config.cargar, Motor y las Accion.
1. Opción global --config RUTA en @app.callback (por defecto ./acciones.json), guardada en ctx.obj.
2. «tareas acciones»: tabla Rich con nombre, tipo y descripción.
3. «tareas ejecutar NOMBRE [CARPETA] --simular --si»:
   - --simular: tabla «fichero → destino» con estado «simulado», sin tocar nada;
   - acción destructiva sin --simular ni --si → typer.confirm(..., abort=True);
   - resumen final: N hechos, N simulados, N saltados; si ningún fichero cumple el filtro, dilo.
4. Códigos de salida: 0 bien · 1 TareasError · 2 uso (Typer) · 3 ConfigError.
   Nunca un traceback ante errores previsibles: mensaje en rojo y typer.Exit(e.codigo_salida).
No toques acciones.py ni motor.py. Termina con: uv run tareas --help

El fragmento clave de cli.py: fíjate en cómo se captura una sola excepción base para todo.

Python
# src/tareas/cli.py (fragmento)
@app.callback()
def principal(
ctx: typer.Context,
config: Path = typer.Option(Path("acciones.json"), "--config", help="Ruta de acciones.json"),
):
ctx.obj = config
​
​
@app.command()
def ejecutar(
ctx: typer.Context,
nombre: str = typer.Argument(..., help="Nombre de la acción (ver: tareas acciones)"),
carpeta: Path | None = typer.Argument(None, help="Carpeta sobre la que actuar"),
simular: bool = typer.Option(False, "--simular", help="Enseña qué haría sin tocar nada"),
si: bool = typer.Option(False, "--si", help="No pedir confirmación"),
):
try:
motor = Motor(config.cargar(ctx.obj))
accion = motor.acciones.get(nombre)
if accion and accion.destructiva and not simular and not si:
typer.confirm(f"«{nombre}» borra ficheros. ¿Seguro?", abort=True)
resultados = motor.ejecutar(nombre, carpeta, simular)
except TareasError as e:
consola.print(f"[red]✘ {e}[/red]")
raise typer.Exit(e.codigo_salida)
pintar_tabla(resultados, simular) # Rich: fichero → destino → estado

Lo que deberías ver (fíjate en el sufijo « (1)»: ya había una factura.pdf en el destino):

$ uv run tareas ejecutar ordenar-descargas ~/pruebas-tareas/Descargas --simular
              ordenar-descargas (SIMULACIÓN: no se ha tocado nada)
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ Fichero               ┃ Destino                                 ┃ Estado   ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━┩
│ apuntes de redes.docx │ ~/Documentos/PDF/apuntes de redes.docx  │ simulado │
│ factura.pdf           │ ~/Documentos/PDF/factura (1).pdf        │ simulado │
└───────────────────────┴─────────────────────────────────────────┴──────────┘
2 ficheros · 0 hechos · 2 simulados · 0 saltados
Para hacerlo de verdad, repite la orden sin --simular.
Las acciones las ejecutas TÚ, no el agente. En tu opencode.json, uv run tareas ejecutar … no está en la lista de permitidas: si el agente quiere lanzarla, OpenCode te pregunta. Recházalo: podría mover tus ficheros reales. Las pruebas las haces tú, en tu terminal o con !uv run tareas … dentro de OpenCode.
Regla de oro: prueba siempre primero en ~/pruebas-tareas y con --simular. Nunca sobre tu carpeta de Descargas de verdad hasta que la simulación diga exactamente lo que esperas. Mientras desarrollas, usa uv run tareas … desde la carpeta del repo (ejecuta tu código actual sin reinstalar).
Ojo con el destino: la carpeta de pruebas protege el origen, pero destino sigue apuntando a tus carpetas reales (~/Documentos/PDF). Mientras pruebas, cambia también el destino a algo como ~/pruebas-tareas/Documentos/PDF en tu acciones.json (o usa un --config de pruebas) y vuelve a tus rutas reales solo cuando todo funcione.

Linux y macOS (bash/zsh)

Bash
# 1) Una carpeta de pruebas con ficheros feos (tildes, espacios, mayúsculas)
mkdir -p ~/pruebas-tareas/Descargas
cd ~/pruebas-tareas/Descargas
touch factura.pdf "apuntes de redes.docx" FOTO1.JPG "cumpleaños 2.png" viejo.tmp
touch -d "40 days ago" viejo.tmp
​
# 2) Desde la carpeta del repo
cd ~/cli-tareas
uv run tareas validar
uv run tareas acciones
uv run tareas ejecutar ordenar-descargas ~/pruebas-tareas/Descargas --simular
uv run tareas ejecutar limpiar-temporales ~/pruebas-tareas/Descargas --simular
​
# 3) Solo cuando la simulación dice lo que esperas
uv run tareas ejecutar ordenar-descargas ~/pruebas-tareas/Descargas
echo $? # 0 si todo ha ido bien
​
# 4) Otro acciones.json (por ejemplo, uno roto a propósito)
uv run tareas --config pruebas/roto.json validar
echo $? # debe ser 3

Windows (PowerShell)

PowerShell
# 1) Una carpeta de pruebas con ficheros feos (tildes, espacios, mayúsculas)
New-Item -ItemType Directory -Force "$HOME\pruebas-tareas\Descargas"
Set-Location "$HOME\pruebas-tareas\Descargas"
New-Item -ItemType File factura.pdf, "apuntes de redes.docx", FOTO1.JPG, "cumpleaños 2.png", viejo.tmp
(Get-Item viejo.tmp).LastWriteTime = (Get-Date).AddDays(-40)
​
# 2) Desde la carpeta del repo
Set-Location "$HOME\cli-tareas"
uv run tareas validar
uv run tareas acciones
uv run tareas ejecutar ordenar-descargas "$HOME\pruebas-tareas\Descargas" --simular
​
# 3) Solo cuando la simulación dice lo que esperas
uv run tareas ejecutar ordenar-descargas "$HOME\pruebas-tareas\Descargas"
$LASTEXITCODE # 0 si todo ha ido bien
​
# 4) Otro acciones.json (por ejemplo, uno roto a propósito)
uv run tareas --config pruebas\roto.json validar
$LASTEXITCODE # debe ser 3

5Tu comando /nueva-accion

Añadir una acción siempre sigue los mismos pasos: proponer el JSON, revisarlo, validar y probar con --simular. Cuando repites un prompt, lo conviertes en un comando personalizado: un fichero Markdown en .opencode/commands/ que se invoca con / y su nombre. Crea .opencode/commands/nueva-accion.md:

Markdown
---
description: Añade una acción nueva a acciones.json y, si hace falta, el código que la soporta
agent: build
---
Quiero añadir la acción: $ARGUMENTS
1. Carga la skill acciones-json.
2. Propón primero la entrada JSON y espera mi visto bueno.
3. Si el tipo ya existe, solo edita acciones.json. Si no, añade la clase en src/tareas/acciones.py siguiendo el patrón de las demás.
4. Termina ejecutando `uv run tareas validar` y dime qué orden con --simular debo probar yo.
Estado actual del repo:
!`git status --short`
  • El nombre del fichero es el nombre del comando: nueva-accion.md → /nueva-accion.
  • Frontmatter: description es lo que ves en la lista al escribir /; agent: build hace que se ejecute con el agente que puede editar (igualmente te pedirá permiso para cada cambio).
  • $ARGUMENTS se sustituye por todo lo que escribas detrás del comando.
  • !`git status --short` ejecuta esa orden en la raíz del repo antes de enviar el prompt y mete su salida: el agente sabe qué ficheros tienes sin guardar.
  • El paso 2 del comando («propón primero la entrada JSON y espera mi visto bueno») es el que mantiene la decisión en tus manos.

Sal de OpenCode, vuelve a abrirlo y úsalo para tu 2.ª acción:

Text
/nueva-accion renombrar-capturas "renombra capturas de pantalla con su fecha"

Lee la entrada JSON que te propone: ¿el filtro coge solo las capturas? ¿La plantilla es la que tú quieres? ¿El destino está en ~/pruebas-tareas mientras pruebas? Corrige lo que no te guste antes de dar el visto bueno. Al final te dirá qué orden con --simular debes probar: la ejecutas tú. Repite con tu 3.ª acción (una idea tuya, no un ejemplo de esta página).

El comando ahorra escribir, no decidir. Las 3 acciones tienen que ser tuyas: qué hacen, con qué ficheros y por qué te sirven. En la defensa te preguntaré por ellas.

6Tu subagente revisor

El agente que escribe el código no es el mejor para revisarlo: ya está convencido de que está bien. Un subagente es otro agente, con su propio prompt y sus propios permisos, que trabaja en una conversación aparte y devuelve el resultado. Crea .opencode/agents/revisor.md:

Markdown
---
description: Revisa el código de la CLI tareas sin modificar nada y señala errores, casos feos y problemas multiplataforma
mode: subagent
temperature: 0.1
permission:
edit: deny
webfetch: deny
bash:
"*": deny
"git diff *": allow
"git log *": allow
"git status *": allow
---
Eres el revisor de código del proyecto (1º DAM). Revisa lo que te indiquen y responde en español con:
1. Errores seguros (con fichero y línea).
2. Casos feos sin cubrir: ruta inexistente, carpeta vacía, nombres con tildes o espacios, JSON roto, destino que ya existe.
3. Problemas en Windows/macOS (rutas, encoding, shell).
4. Una pregunta que el alumno debería saber responder sobre ese código.
No reescribas el código: señala y explica.
  • El nombre del fichero es el nombre del agente: revisor.md → @revisor.
  • mode: subagent: no aparece al cambiar con Tab (eso es para plan y build); se le llama con @revisor, o el agente principal lo lanza si su description encaja con lo que necesita.
  • permission: edit: deny y webfetch: deny lo dejan en solo lectura; de bash solo puede usar git diff, git log y git status; cualquier otra orden está bloqueada ("*": deny va primero y las excepciones después: gana la última regla que coincide). Aunque quisiera, no puede «arreglarte» nada ni ejecutar tus acciones.
  • temperature: 0.1 pide respuestas poco creativas (un revisor tiene que ser previsible), si el modelo del centro la admite; si no, se ignora sin romper nada.
  • El cuerpo es su prompt de sistema: qué revisar y en qué formato responder.

Sal, vuelve a abrir OpenCode (en la terminal, opencode agent list debe mostrar revisor) y pídele:

Text
@revisor revisa @src/tareas/motor.py

Además, OpenCode trae /review, que revisa los cambios sin commitear. La rutina a partir de hoy: antes de cada commit de código, /review o @revisor.

Tú decides qué se arregla. El revisor también se equivoca: a veces ve fallos que no existen o propone cosas que no tocan esta semana. Por cada punto, compruébalo (léelo, pruébalo en ~/pruebas-tareas) y decide: lo arreglas (con build, en otra petición), lo apuntas como mejora en el README o lo descartas sabiendo por qué. Y contesta tú la pregunta que te hace al final.

7Tu primer ADR

Un ADR (Architecture Decision Record, registro de decisión de arquitectura) es un documento corto que cuenta una decisión técnica importante: qué decidiste, por qué y qué alternativas descartaste. Se escribe para la persona que llegue dentro de seis meses (muchas veces, tú mismo) y se pregunte «¿por qué narices está hecho así?». Los equipos profesionales los guardan numerados en docs/adr/ y no se borran: si cambias de opinión, escribes uno nuevo que sustituye al anterior.

Tu primera decisión de arquitectura es la de esta semana. Crea docs/adr/0001-configuracion-externa.md con esta plantilla y termínala con TUS razones (los huecos [...]).

Markdown
# ADR-0001: Las acciones viven en acciones.json, no en el código
​
- Estado: aceptada
- Fecha: 2026-11-04
- Autor/a: [tu nombre]
​
## Contexto
​
Mi herramienta «tareas» automatiza tareas repetitivas con ficheros
(ordenar Descargas, renombrar fotos, limpiar temporales...). Cada persona
que la use tendrá carpetas y manías distintas, y yo mismo voy a querer
añadir acciones nuevas cada pocas semanas. [Añade TU situación: qué
acciones tienes y quién más la va a usar.]
​
## Decisión
​
Las acciones se describen en un fichero JSON externo (acciones.json) con
un esquema documentado en el README. El código solo contiene el MOTOR que
lee, valida y ejecuta ese fichero. Añadir una acción = editar el JSON.
​
## Alternativas consideradas
​
1. Escribir cada acción como una función en Python.
- A favor: [...]
- En contra: cada cambio exige tocar el código y volver a instalar; quien
no programa no puede añadir acciones.
2. Usar YAML o TOML en vez de JSON.
- A favor: [...]
- En contra: [...]
3. [Otra que se te ocurra o que te propusiera la IA]
​
## Consecuencias
​
- Positivas: añadir una acción no toca el código; el JSON se puede validar
antes de ejecutar nada; [...]
- Negativas: hay que mantener la validación al día cuando el esquema
crezca; los errores de escritura en el JSON solo se ven al ejecutar
validar; [...]
- Qué haré si el esquema cambia: [por ejemplo, subir "version" a 2 y ...]

Puedes usar al agente en plan (no puede editar) como abogado del diablo: «Lee @docs/adr/0001-configuracion-externa.md y dame 3 argumentos en contra de mi decisión y una alternativa que no haya considerado». Pero el texto final es tuyo: en la defensa te preguntaré por él.

8Audita, prueba en otro sistema y commitea

«Lo que no sabes explicar, no lo entregas». Antes de dar por buena cada acción, pasa esta auditoría en~/pruebas-tareas:

PreguntaLo que debería pasar
¿Qué pasa si ejecuto dos veces la misma acción?Mover: la segunda vez no encuentra nada en origen (bien). Renombrar: no debería generar 2026-10-26_001_001… ni cambiar números a lo loco. Copiar: aparecen «(1)», no se machaca nada.
¿Y si la carpeta de destino no existe?Se crea al ejecutar de verdad; con --simular NO se crea (compruébalo con ls / dir).
¿Y si el destino ya tiene un fichero con ese nombre?Sufijo « (1)» y la tabla lo enseña. Nunca se pierde un fichero.
¿Nombres con tildes, eñes o espacios?«cumpleaños 2.png» y «apuntes de redes.docx» se mueven y se ven bien en la tabla.
¿Mayúsculas en la extensión?FOTO1.JPG entra en el filtro de .jpg.
¿La carpeta de origen no existe o la acción no existe?Mensaje humano en rojo y código de salida 1, sin traceback.
¿Borrar sin --si?Pregunta antes. Si contestas que no, no borra nada y sale con código distinto de 0.
¿Dos ficheros que se renombran al MISMO nombre en una simulación?Caso traicionero: destino_libre mira el disco, y en simulación el primero aún no existe. ¿Tu tabla lo detecta? Si no, apúntalo como mejora en el README.

Prueba en otro sistema si puedes. En el aula tienes Linux; si en casa usas Windows o macOS, clona el repo allí y repite los bloques de prueba del paso 4. Los fallos típicos aparecen aquí: rutas con \, tildes que salen como ñ (falta encoding="utf-8") o colores raros en una consola antigua. Cada fallo que encuentres y arregles es un commit con historia.

Un commit por pieza, hecho por ti (el agente no hace commits ni push: lo dice tu AGENTS.md y lo bloquea tu opencode.json). Los ficheros de .opencode/ también se commitean: son parte de tu proyecto. Algo así, con tus palabras:

Bash
git add acciones.json README.md
git commit -m "Esquema de acciones.json con mi primera acción, documentado en el README"
​
git add .opencode/skills/acciones-json/SKILL.md
git commit -m "Skill acciones-json: el agente carga las reglas de mi esquema"
​
git add src/tareas/errores.py src/tareas/config.py src/tareas/cli.py
git commit -m "Validación de acciones.json con ConfigError/AccionNoValida y orden validar (sale con 3)"
​
git add src/tareas/acciones.py src/tareas/motor.py
git commit -m "Motor y clases Accion: --simular no toca el disco y nunca sobrescribe"
​
git add src/tareas/cli.py
git commit -m "Órdenes acciones y ejecutar con tabla Rich, --si y --config"
​
git add .opencode/commands/nueva-accion.md
git commit -m "Comando /nueva-accion: propone el JSON y espera mi visto bueno"
​
git add acciones.json
git commit -m "Acción renombrar-capturas creada con /nueva-accion y probada con --simular"
​
git add .opencode/agents/revisor.md
git commit -m "Subagente revisor de solo lectura (edit: deny)"
​
git add src/tareas/acciones.py
git commit -m "Arreglo del revisor: nombres con tildes al renombrar"
​
git add docs/adr/0001-configuracion-externa.md docs/prompts.md
git commit -m "ADR-0001: por qué configuración externa; prompts clave de la semana"
git push

Y en docs/prompts.md, los prompts de la validación, del motor y de la CLI, con una línea por prompt de qué tuviste que corregir de lo que hizo el agente y qué te señaló el revisor. Es la prueba de que auditaste.

Checklist de la semana

Lo que cuenta en la rúbrica esta semana

Antes que nada, los 4 mínimos de CLI «tareas con IA»: si falla uno, el proyecto no se evalúa. Esta semana ya puedes cumplir el segundo y avanzar mucho en el cuarto.

  1. 1Se instala siguiendo el README y la orden de ayuda (--help) funciona.
  2. 2Al menos 3 acciones propias definidas en el fichero de configuración (acciones.json) funcionan.
  3. 3Ninguna clave ni secreto en el repositorio ni en su historial.
  4. 4Historial git real: mínimo 10 commits con mensajes que cuentan el proceso.

Y estos criterios se juegan casi enteros con el trabajo de hoy:

CriterioPesoNivel más alto
Acciones predefinidas funcionando20 %5 o más acciones propias útiles, combinables, con salida clara y sin efectos sorpresa (simulación previa o confirmación antes de borrar o sobrescribir).
Configuración externa (acciones.json)15 %Además: añadir una acción no exige tocar el código y hay un ADR que justifica por qué configuración externa y no código.
Robustez: excepciones y entradas feas15 %Excepciones propias (ConfigError, AccionNoValida…) con mensajes humanos; ningún traceback ante errores previsibles; aserciones en desarrollo.
Diseño en clases y módulos10 %Responsabilidades separadas (interfaz, motor, acciones, proveedor IA), visibilidad cuidada y métodos estáticos con sentido (p. ej., fábricas desde la configuración).

Qué viene después

Semana 3 · MCP y capa IA

OpenCode con MCP: el servidor context7 para consultar documentación de librerías, activado solo cuando hace falta. Y la capa IA de tu CLI: acciones que deciden con los mismos modelos del centro, llamados desde Python, con la clave fuera del repo y modo sin red.

Semana 4 · Pruebas y release

Pruebas con pytest que escribe el agente y auditas tú, opencode run sin interfaz (por ejemplo, en scripts), README de instalación y release v1.0.

Semipresencial · Quincena 4

Del 9 al 20 de noviembre: IA, pruebas y release. Entrega el viernes 20 de noviembre.