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.
Producto al acabar la semana
- 3 acciones propias en acciones.json funcionando, siempre primero con
--simular. - Las órdenes
tareas validar,tareas accionesytareas ejecutar. - Excepciones propias:
TareasError,ConfigError,AccionNoValida. docs/adr/0001-configuracion-externa.mdescrito con tus razones.- En el repo: la skill
.opencode/skills/acciones-json/, el comando/nueva-acciony el subagenterevisor. - ≥ 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.
| Fichero | Quién lo interpreta | Qué decides sin tocar el programa |
|---|---|---|
| .gitignore | git | Qué ficheros no se suben (lo escribiste en la Sesión 1). |
| package.json | npm / Node | Dependencias y órdenes como npm run dev. |
| docker-compose.yml | Docker | Qué servicios arrancan, en qué puertos, con qué variables. |
| settings.json de VS Code | VS Code | Tema, tamaño de letra, formateo al guardar… |
| pyproject.toml | uv / pip | Nombre del paquete, versión y el comando tareas. |
| opencode.json · AGENTS.md · .opencode/ | OpenCode | Qué puede hacer tu agente, qué normas sigue y qué sabe de tu proyecto. |
| acciones.json | TU motor | Qué hace tu herramienta con tus ficheros. |
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.
← desliza el diagrama →
El método de cada paso (el mismo de la Sesión 1)
- Tú escribes la intención: el esquema, la acción, el criterio de aceptación.
- Agente plan (
Tab): «explícame cómo lo harías y qué ficheros tocarías». Lees el plan y lo corriges. - Agente build (
Tab): «hazlo». Apruebas cada edición y cada orden leyendo el diff. - Auditas: lees el código, pruebas en
~/pruebas-tareascon--simulary con casos feos. Si algo sale mal,/undoo le explicas el fallo. - Commit pequeño hecho por ti y el prompt clave a
docs/prompts.md(o/exportde la sesión). - Sesión nueva por tarea (
/new);/compactsi 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:
{ "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
| Campo | Obligatorio | Tipo | Ejemplo | Qué hace |
|---|---|---|---|---|
| version | sí (raíz) | entero | 1 | Versión del esquema. Si mañana cambias el formato, subes a 2 y el programa sabe qué está leyendo. |
| acciones | sí (raíz) | lista | [ … ] | La lista de acciones. Vacía = error de configuración. |
| nombre | sí | texto sin espacios | ordenar-descargas | Lo que escribes en tareas ejecutar NOMBRE. Único: no puede haber dos iguales. |
| descripcion | sí | texto | Mueve los PDF… | Lo que enseña tareas acciones. Escríbela para alguien que no eres tú. |
| tipo | sí | uno de la lista | mover | Qué clase de acción es. El motor elige la clase Python a partir de este campo. |
| origen | no | ruta | ~/Descargas | Carpeta por defecto si no pasas CARPETA en la orden. ~ se expande a tu carpeta personal en Linux, macOS y Windows. |
| filtro | no | objeto | { "extensiones": [".pdf"] } | Qué ficheros de la carpeta se tocan. Sin filtro = todos (¡cuidado con borrar!). |
| destino | mover / copiar | ruta | ~/Documentos/PDF | A dónde van. Si no existe, se crea. |
| plantilla | renombrar | texto con {huecos} | {fecha:%Y-%m-%d}_{n:03}{ext} | El nombre nuevo de cada fichero. |
| confirmar | no (borrar: sí) | true / false | true | Pide confirmación antes de actuar salvo que pases --si. |
Tipos de acción
| tipo | Qué le pasa al fichero | Para qué en tu vida real |
|---|---|---|
| mover | origen → destino | Ordenar Descargas, sacar las capturas del escritorio. |
| copiar | origen → destino (el original se queda) | Copia de seguridad de tus apuntes a un USB o a una carpeta sincronizada. |
| renombrar | mismo sitio, nombre nuevo | Fotos con fecha, apuntes numerados (tema-01, tema-02…). |
| borrar | desaparece (con confirmación) | Temporales, instaladores .exe/.deb viejos, ficheros vacíos. |
| … con IA (semana 3) | lo decide un modelo del centro | Clasificar 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_dias | 30 | Solo ficheros cuya fecha de modificación tiene más de N días. |
| mayor_que_mb | 100 | Solo 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-03 | Fecha 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} | .jpg | La extensión original, con punto, en minúsculas. |
| {nombre} | IMG_2041 | El nombre original sin extensión, por si quieres conservarlo: {fecha:%Y%m%d}-{nombre}{ext}. |
/nueva-accion. Ideas, de menos a más difícil:- Fácil: mover las capturas de pantalla del escritorio a
~/Imágenes/Capturas(filtro por extensión). - Media: copiar los apuntes de DAM modificados hace menos de 7 días a una carpeta de copia, o borrar instaladores
.exe/.debde más de 60 días. - Difícil: renombrar las fotos del móvil a
{fecha:%Y-%m-%d}_{n:03}{ext}usando unpatronregex que solo coja las que empiezan porIMG_oPXL_.
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:
| Pieza | Dónde vive | Quién la activa | Para qué |
|---|---|---|---|
| AGENTS.md | raíz del repo | Siempre: entra en todas las conversaciones | Normas generales del proyecto (stack, órdenes, «no hagas push»). |
| Skill | .opencode/skills/NOMBRE/SKILL.md | El agente, bajo demanda, cuando la description encaja con la tarea | Conocimiento concreto que no hace falta siempre: tu esquema de acciones.json. |
| Comando | .opencode/commands/NOMBRE.md | Tú, escribiendo /NOMBRE argumentos | Un prompt que repites a menudo, con sus pasos fijos. |
| Subagente | .opencode/agents/NOMBRE.md | Tú 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. |
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:
---name: acciones-jsondescription: 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.nametiene que ser igual que el nombre de la carpeta (acciones-json), en minúsculas y con guiones. descriptiones 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
skilly 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.
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.
git add .opencode/skills/acciones-json/SKILL.mdgit 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.
# src/tareas/errores.pyclass TareasError(Exception): """Base de todos los errores previsibles de la herramienta.""" codigo_salida = 1class ConfigError(TareasError): """acciones.json no existe, no es JSON o no cumple el esquema.""" codigo_salida = 3class 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 = campoY así es, resumido, config.py. No lo copies: es la referencia para comparar con lo que te haya hecho el agente.
# src/tareas/config.py (resumido)import jsonimport refrom pathlib import Pathfrom .errores import AccionNoValida, ConfigErrorTIPOS = {"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 accionesPrueba la orden y su código de salida (en tu terminal, o desde OpenCode con ! delante):
uv run tareas validarecho $? # Linux/macOS: 0 si es válido, 3 si no$LASTEXITCODE # lo mismo en PowerShellCasos 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».
| Caso | Cómo provocarlo | Lo que debe decir |
|---|---|---|
| Coma de más | Una 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 repetido | Dos 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.json | No 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é.
pathlib.Pathsiempre: ninguna ruta montada con+ "/" +ni con\.Path.expanduser()para~;encoding="utf-8"en todoopen()oread_text().shutil.moveyshutil.copy2; nada deos.system,subprocessnishell=True.- Nombres con tildes y espacios funcionan.
- Con
simular=Trueno se llama amkdir,move,renameniunlink. - Nunca se sobrescribe: si el destino existe, sufijo « (1)», « (2)»…
docs/prompts.md.Código de referencia resumido para auditar:
# src/tareas/acciones.py (resumido)from __future__ import annotationsimport shutilfrom abc import ABC, abstractmethodfrom dataclasses import dataclassfrom datetime import datetimefrom pathlib import Pathfrom .errores import AccionNoValida@dataclassclass 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 libreclass 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).# src/tareas/motor.py (resumido)import reimport timefrom pathlib import Pathfrom .acciones import Accion, Resultadofrom .errores import TareasErrorclass 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 salida | Significa |
|---|---|
| 0 | Todo bien. |
| 1 | Fallo al ejecutar (carpeta que no existe, acción desconocida, permiso denegado…). |
| 2 | Error de uso: falta un argumento o una opción no existe (lo pone Typer solo). |
| 3 | Configuració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.
# 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 → estadoLo 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.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.~/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).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)
# 1) Una carpeta de pruebas con ficheros feos (tildes, espacios, mayúsculas)mkdir -p ~/pruebas-tareas/Descargascd ~/pruebas-tareas/Descargastouch factura.pdf "apuntes de redes.docx" FOTO1.JPG "cumpleaños 2.png" viejo.tmptouch -d "40 days ago" viejo.tmp# 2) Desde la carpeta del repocd ~/cli-tareasuv run tareas validaruv run tareas accionesuv run tareas ejecutar ordenar-descargas ~/pruebas-tareas/Descargas --simularuv run tareas ejecutar limpiar-temporales ~/pruebas-tareas/Descargas --simular# 3) Solo cuando la simulación dice lo que esperasuv run tareas ejecutar ordenar-descargas ~/pruebas-tareas/Descargasecho $? # 0 si todo ha ido bien# 4) Otro acciones.json (por ejemplo, uno roto a propósito)uv run tareas --config pruebas/roto.json validarecho $? # debe ser 3Windows (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 repoSet-Location "$HOME\cli-tareas"uv run tareas validaruv run tareas accionesuv run tareas ejecutar ordenar-descargas "$HOME\pruebas-tareas\Descargas" --simular# 3) Solo cuando la simulación dice lo que esperasuv 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 35Tu 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:
---description: Añade una acción nueva a acciones.json y, si hace falta, el código que la soportaagent: build---Quiero añadir la acción: $ARGUMENTS1. 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:
descriptiones lo que ves en la lista al escribir/;agent: buildhace que se ejecute con el agente que puede editar (igualmente te pedirá permiso para cada cambio). $ARGUMENTSse 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:
/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).
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:
---description: Revisa el código de la CLI tareas sin modificar nada y señala errores, casos feos y problemas multiplataformamode: subagenttemperature: 0.1permission: 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 conTab(eso es para plan y build); se le llama con@revisor, o el agente principal lo lanza si sudescriptionencaja con lo que necesita.permission:edit: denyywebfetch: denylo dejan en solo lectura; debashsolo puede usargit diff,git logygit status; cualquier otra orden está bloqueada ("*": denyva primero y las excepciones después: gana la última regla que coincide). Aunque quisiera, no puede «arreglarte» nada ni ejecutar tus acciones.temperature: 0.1pide 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:
@revisor revisa @src/tareas/motor.pyAdemá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.
~/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 [...]).
# ADR-0001: Las acciones viven en acciones.json, no en el código- Estado: aceptada- Fecha: 2026-11-04- Autor/a: [tu nombre]## ContextoMi herramienta «tareas» automatiza tareas repetitivas con ficheros(ordenar Descargas, renombrar fotos, limpiar temporales...). Cada personaque la use tendrá carpetas y manías distintas, y yo mismo voy a quererañadir acciones nuevas cada pocas semanas. [Añade TU situación: quéacciones tienes y quién más la va a usar.]## DecisiónLas acciones se describen en un fichero JSON externo (acciones.json) conun esquema documentado en el README. El código solo contiene el MOTOR quelee, valida y ejecuta ese fichero. Añadir una acción = editar el JSON.## Alternativas consideradas1. 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:
| Pregunta | Lo 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:
git add acciones.json README.mdgit commit -m "Esquema de acciones.json con mi primera acción, documentado en el README"git add .opencode/skills/acciones-json/SKILL.mdgit 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.pygit 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.pygit commit -m "Motor y clases Accion: --simular no toca el disco y nunca sobrescribe"git add src/tareas/cli.pygit commit -m "Órdenes acciones y ejecutar con tabla Rich, --si y --config"git add .opencode/commands/nueva-accion.mdgit commit -m "Comando /nueva-accion: propone el JSON y espera mi visto bueno"git add acciones.jsongit commit -m "Acción renombrar-capturas creada con /nueva-accion y probada con --simular"git add .opencode/agents/revisor.mdgit commit -m "Subagente revisor de solo lectura (edit: deny)"git add src/tareas/acciones.pygit commit -m "Arreglo del revisor: nombres con tildes al renombrar"git add docs/adr/0001-configuracion-externa.md docs/prompts.mdgit commit -m "ADR-0001: por qué configuración externa; prompts clave de la semana"git pushY 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
- acciones.json con al menos 3 acciones TUYAS (no las del ejemplo) y el esquema documentado en el README.
- «tareas validar» detecta los casos feos con mensajes que dicen qué acción y qué campo fallan, y sale con 3.
- errores.py con TareasError, ConfigError y AccionNoValida; ningún traceback ante errores previsibles.
- acciones.py con la clase base Accion, la fábrica desde_config y una subclase por tipo; motor.py con la clase Motor.
- «tareas acciones» y «tareas ejecutar NOMBRE [CARPETA] --simular / --si» funcionando, y --config RUTA.
- Probado en ~/pruebas-tareas y con el destino también en pruebas: siempre --simular primero; nada sobrescrito, nada perdido.
- .opencode/skills/acciones-json/SKILL.md en el repo y visible en /skills.
- .opencode/commands/nueva-accion.md en el repo y usado para tu 2.ª y 3.ª acción.
- .opencode/agents/revisor.md en el repo; @revisor o /review antes de cada commit de código.
- docs/adr/0001-configuracion-externa.md terminado con TUS razones.
- docs/prompts.md con los prompts clave de la semana y qué tuviste que corregir de lo que hizo el agente.
- ≥ 10 commits tuyos acumulados (los de la Sesión 1 cuentan) con mensajes que cuentan el proceso, y push a GitHub.
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.
- 1Se instala siguiendo el README y la orden de ayuda (--help) funciona.
- 2Al menos 3 acciones propias definidas en el fichero de configuración (acciones.json) funcionan.
- 3Ninguna clave ni secreto en el repositorio ni en su historial.
- 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:
| Criterio | Peso | Nivel más alto |
|---|---|---|
| Acciones predefinidas funcionando | 20 % | 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 feas | 15 % | Excepciones propias (ConfigError, AccionNoValida…) con mensajes humanos; ningún traceback ante errores previsibles; aserciones en desarrollo. |
| Diseño en clases y módulos | 10 % | 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.
