Todos los proyectos
v0.2.1 · en desarrolloProyecto 10
CLI extensiblePlugins de GitDevTools

EagleKit

CLI de proyectos con sistema de plugins por entry points de Python. Lo difícil no fueron los comandos: fue coordinar dos hooks de Git que son procesos separados, sin memoria compartida y con un humano que puede abortar entre los dos.

Python 3.9+TyperRichimportlib.metadataGit hooksSQLite / JSON

Poner [7][pagos-qa] delante de un commit parece una línea de bash. Son dos procesos que nunca coinciden en el tiempo.

EagleKit es un CLI de proyectos (ek) que crece con plugins instalables con pip. Los plugins de Git numeran commits por rama y espejan QA → Prod con cherry-pick. Lo difícil no fueron los comandos: fue que Git lanza cada hook como un proceso nuevo, sin memoria compartida, y un humano puede abortar entre los dos.

proceso 1 · prepare-commit-msg
Sube el contador, genera el UUID, escribe el prefijo. Y muere.
entre medias · tú
Guardas el mensaje o cierras el editor. Nadie se lo cuenta al proceso 1.
proceso 2 · post-commit
Es el único que conoce el hash. Necesita el UUID del proceso 1.
4dependencias de runtime en el core: Typer, Rich, PyYAML, platformdirs
38subcomandos en el core; 19 más en los dos plugins públicos
2hooks de Git por repositorio: prepare-commit-msg y post-commit
01 · Cómo funciona

Un comando que no sabe qué subcomandos tendrá mañana

Pulsa los pasos o deja que avance. El core no importa a ningún plugin: los descubre en el momento de arrancar.

Tu shell
función ek()
ek-core
app Typer + Rich
entry points
importlib.metadata
Plugin
register(app)
Estado
YAML · JSON · SQLite
Paso 1 de 5 · avanza solo
Escribes ek mirror push
La función ek() instalada en tu .zshrc mira si el comando es ek cd. Si no lo es, delega tal cual en ek-core, el proceso Python. Un subproceso no puede cambiar el directorio de tu shell; por eso el cd se queda fuera de Python.

Un plugin entero es una función y una línea en pyproject.toml.

# eaglekit_plugin_mirror/__init__.py
def register(app):
app.add_typer(mirror_app, name="mirror")
# pyproject.toml
[project.entry-points."eaglekit.plugins"]
mirror = "eaglekit_plugin_mirror:register"
$ pipx inject eaglekit eaglekit-plugin-mirror
$ ek mirror --help
02 · Arquitectura

Un wrapper de shell, un proceso Python y tres sitios donde vive el estado

de la tecla al fichero
Shell
ek()
función en .zshrc / .bashrc
intercepta ek cd
ek
entry point Python
wrapper
todo lo demás se delega en ek-core
CLI
ek-core
app Typer · salida Rich · 38 subcomandos
v0.2.1
entry points · eaglekit.plugins
Plugins
tracker
[n][rama] + UUID por commit
11 comandos
mirror
QA ↔ Prod con cherry-pick
8 comandos
flow
tracker + mirror unificados
SQLite
cada uno escribe en .git/ de su repo
Estado
~/.config/
eaglekit/registry.yaml · defaults.yaml
.eagle/
config.yaml · todos.yaml · scripts/
.git/eaglekit_*
branches/*.json · links.json · flow.db

Lo que hace el core sin ningún plugin instalado.

Proyectos
ek add ., ek list, ek status. Workspaces para separar trabajo, personal y clientes.
Navegación
ek add genera ~/.eagle_projects con una variable por proyecto: cd $api. O ek cd api vía la función de shell.
Tareas por rama
YAML en .eagle/config.yaml; .eagle/branches/<rama>/ sobreescribe por nombre. String, lista o script.
TODOs y notas
Por proyecto, en YAML dentro de .eagle/. 15 subcomandos entre todo y comment.
Ignore de .eagle/
Cuatro estrategias: local (.git/info/exclude), repo, global o none. La recomendada no toca el repo de nadie más.
Plugins
ek plugins lista cargados y fallidos con su error. Asistente de primer arranque con ek setup.
03 · Los hooks

Qué pasa dentro de git commit cuando el tracker está instalado

Cada hook es un proceso nuevo que Git lanza y descarta. Este es el flujo real del plugin público, paso a paso.

git commit
tu terminal
prepare-commit-msg
proceso 1
Tu editor
guardas o abortas
post-commit
proceso 2
.git/…
branches/<rama>.json
Paso 1 de 4 · avanza solo
Git lanza el primer hook y muere con él
prepare-commit-msg es un script bash con Python embebido. Lee el contador de la rama, lo sube de 6 a 7, genera un UUID y antepone [7][pagos-qa] al mensaje. Cuando termina, su memoria desaparece.
$ ek tracker init
✓ hooks instalados: prepare-commit-msg, post-commit
$ git commit -m "Valida el monto del pago"
[7][pagos-qa] Valida el monto del pago
# amend, merge, squash y cherry-pick no numeran: el hook los detecta y sale
$ SKIP_EAGLEKIT_HOOK=1 git commit -m "hotfix sin prefijo"
04 · Un ejemplo de punta a punta

Ana lleva pagos en dos ramas

Un caso inventado con tres commits, marcado como ejemplo. Los comandos y los ficheros son los reales.

1
lunes · repo de la API
Crea el par de ramas
ek mirror create pagos crea pagos-qa y pagos-prod y las enlaza en .git/eaglekit_mirror/links.json. ek tracker init instala los dos hooks.
2
lunes → miércoles
Tres commits en QA
Cada uno sale con su prefijo: [1][pagos-qa], [2][pagos-qa], [3][pagos-qa]. El segundo hook guarda índice, hash y UUID en branches/pagos-qa.json.
3
jueves
Sube QA a Prod
ek mirror push lista los commits de QA cuyo UUID no está en pushed_logical_ids y los aplica con git cherry-pick, uno a uno. Antes de cada uno deja un fichero de metadata para que el hook conserve el UUID.
ek mirror push · el hash cambia, el logical_id no
pagos-qa
[1]a1f3c02
[2]7c0e9d1
[3]e9b2447
git cherry-pick, uno a uno · el UUID viaja en un fichero de metadata
pagos-prod
[1]5d21b8e
[2]c47a610
[3]conflicto
El tercero conflictúa. El estado guarda pending_commits y se sale. Resuelves en el editor y sigues con ek mirror push --continue: el filtro por UUID no vuelve a aplicar el 1 ni el 2.
4
jueves · un minuto después
El tercero conflictúa
La operación se detiene. sync_state.json guarda los pendientes y marca in_progress. Ana resuelve el conflicto, hace git cherry-pick --continue y ek mirror push --continue termina lo que faltaba.
5
viernes
Comprueba y sigue
ek mirror status: 0 pendientes en cada sentido. Un hotfix hecho directamente en Prod vuelve a QA con ek mirror pull por el mismo mecanismo.
05 · Los retos

Cinco veces en que el happy path no era el problema

Mismo patrón cada vez: lo que cualquiera haría primero, por qué no sirve, lo que se hizo. El detalle técnico queda plegado.

01

Dos hooks, un número y un humano en medio

Lo obvio
Que prepare-commit-msg lea el contador, lo suba y escriba el prefijo. Listo.
Por qué falla
El contador se incrementa antes de que el commit exista. Si cierras el editor sin guardar, queda un hueco: el 7 se gastó y nadie lo usó. Y el hash, que es lo que hay que guardar, solo lo conoce post-commit, otro proceso que arranca cuando el primero ya murió. No hay variable de entorno que sobreviva a ese salto.
Lo que se hizo
En los plugins públicos, el primer hook deja el UUID en .git/eaglekit_pending_uuid y el segundo lo consume y lo borra. En Flow, la versión unificada, el contador vive en SQLite con INSERT … ON CONFLICT DO UPDATE SET next_seq = next_seq + 1, y el diseño tolera huecos: los commits se identifican por logical_id, no por una secuencia sin saltos.
1 ficherode un solo uso es todo el canal entre los dos procesos. Si el commit se aborta, el siguiente prepare-commit-msg lo sobreescribe.
Ver detalle técnico

Los hooks se instalan como bash con Python embebido en heredoc, sin importar el paquete: así funcionan aunque el entorno virtual del CLI no esté activo en la terminal donde haces el commit. Los valores pasan al Python por variables de entorno para evitar problemas de quoting con mensajes multilínea.

El hook se salta merges, squash, cherry-pick y --amend leyendo el segundo argumento que Git le pasa, y respeta SKIP_EAGLEKIT_HOOK=1. Documentado en el ARCHITECTURE.md del tracker.

02

El cherry-pick cambia el hash, no la identidad

Lo obvio
Recordar qué hashes ya se sincronizaron y no volver a aplicarlos.
Por qué falla
Un cherry-pick crea un commit nuevo, con otro hash. El mismo cambio tiene un hash en pagos-qa y otro en pagos-prod. Comparar por hash lo aplica dos veces. Y el hook que registra el commit en Prod se ha saltado a sí mismo porque detectó un cherry-pick: nadie le dice qué UUID tenía el original.
en pagos-qa
tras cherry-pick a pagos-prod
hash
e9b2447
91be0c3 · cambia
prefijo
[3][pagos-qa]
[3][pagos-qa] · va en el mensaje
logical_id
550e8400-…
550e8400-… · se conserva
El sync compara UUIDs, nunca hashes. Un commit ya sincronizado no se vuelve a aplicar aunque su hash sea distinto en cada rama.
Lo que se hizo
Cada commit lleva un logical_id (UUID v4) generado una sola vez. El plugin mirror escribe ese UUID en un fichero de metadata dentro de .git/ antes de cada cherry-pick; el post-commit le da prioridad sobre el UUID pendiente y lo borra. El filtro de sincronización compara UUIDs y cae a hashes solo si no hay UUID.
2 → 1plugins. Coordinar tracker y mirror por ficheros con nombre acordado resultó frágil; se unificaron en Flow con una sola base de datos.
Ver detalle técnico

El orden importa: metadata, cherry-pick, leer el nuevo HEAD, guardar estado. Si el cherry-pick conflictúa, el metadata se limpia antes de salir para que el siguiente commit normal no herede un UUID ajeno.

En las versiones públicas, el mirror escribe .git/eaglekit_hooks_metadata.json y el hook del tracker lee .git/eaglekit_commit_tracker_metadata.json. Dos nombres para el mismo contrato: el commit espejado queda sin fila en el JSON del tracker. Es exactamente el tipo de fallo que motivó unificarlos.
03

Amend y rebase dejan hashes fantasma

Lo obvio
Guardar el hash en el commit y confiar en que no cambie.
Por qué falla
git commit --amend y git rebase reescriben hashes. La base de datos sigue apuntando a commits que ya no existen, y un push intentaría cherry-pickear fantasmas. Nada de esto aparece en el happy path.
Lo que se hizo
En el tracker público, el hook no numera amends y la renumeración explícita es un comando aparte: ek tracker rewrite --dry-run, luego --force, con una copia JSON de los mapeos antes de tocar nada. En Flow se añadió el tercer hook, post-rewrite: Git le pasa por stdin los pares viejo → nuevo y cada par es un UPDATE en una transacción.
3 hooksen Flow: commit-msg, post-commit y post-rewrite. El tercero fue el que casi se queda fuera.
Ver detalle técnico

La alternativa no destructiva es ek tracker reindex: corrige la numeración interna sin cambiar el historial. rewrite solo cuando el mensaje del commit tiene que mostrar el número correcto, y después git push --force-with-lease.

04

Un plugin roto no puede matar el CLI

Lo obvio
Iterar los entry points, hacer ep.load() y llamar a lo que devuelva.
Por qué falla
Tres cosas distintas fallan ahí. La API de entry_points() devuelve un dict en Python 3.9 y un objeto con .select() en 3.10+. Un plugin con un error de import tumba todo ek, incluido ek list, que no lo necesita. Y nada garantiza que el entry point sea invocable: el error real en uso fue 'module' object is not callable.
$ ek plugins
Plugin Module Status Error
mirror eaglekit_plugin_mirror:register ✓ Loaded
tracker eaglekit_plugin_commit_tracker:register ✓ Loaded
demo eaglekit_plugin_demo ✗ Failed entry point is not callable
Lo que se hizo
hasattr(eps, 'select') en runtime para las dos APIs. Un try/except por plugin que separa el fallo de validación del fallo de carga y guarda ambos con su mensaje. ek plugins muestra cargados y fallidos en la misma tabla.
stderrpara todos los mensajes del loader. Antes iban a stdout y rompían ek list | grep api.
05

Colisión silenciosa de nombres en Typer

Lo obvio
Que cada plugin registre su grupo y Typer se encargue del resto.
Por qué falla
Typer no detecta que dos plugins llamen a add_typer con el mismo nombre. El segundo sobreescribe al primero sin error ni aviso. Se descubrió probando tracker y mirror como plugins separados: si ambos hubieran elegido sync, uno habría desaparecido.
plugin A
app.add_typer(sub, name="sync")
plugin B
app.add_typer(sub, name="sync")
→
ek sync
El segundo en cargarse gana. Sin error, sin aviso: el primero desaparece.
Lo que se hizo
Se unificaron en un solo plugin, Flow, con un solo namespace. La convención de nombres eaglekit-plugin-<nombre> reduce el riesgo, pero no lo elimina.
0 avisoshoy. El loader todavía no detecta colisiones: está en el backlog como caso documentado. No afecta a la instalación estándar, sí a plugins de terceros.
"El 80 % del tiempo de depuración se fue en escenarios de borde."
Diseñar el happy path tomó un día. Que nada se rompa cuando alguien aborta un commit a medias, hace amend, o el quinto de cinco cherry-picks conflictúa, tomó semanas. La complejidad de un CLI no está en los comandos: está en la resiliencia.
06 · Decisiones

Dónde poner cada cosa, y por qué

Casi todas son la misma pregunta: ¿esto lo necesita quien solo quiere ek add . y ek list?

WHY
¿Por qué dos entry points, ek y ek-core, y una función de shell?
Porque un subproceso no puede hacer cd en el shell que lo lanzó. La función ek() intercepta ek cd api, pide la ruta con ek-core cd --path api y ejecuta el cd real. Todo lo demás pasa de largo a Python. Es el mismo patrón que nvm, pyenv y direnv.
Profundizar

ek shell install añade la función entre marcadores en .zshrc o .bashrc; uninstall quita solo ese bloque. Además ek add regenera ~/.eagle_projects con export api="/ruta" por proyecto, así que cd $api funciona sin la función.

Existe un router.py que traduce ek deploy en ek-core run task deploy, y el README lo documenta, pero en v0.2.1 el entry point ek apunta al wrapper y no al router. Hoy el atajo no está cableado: la forma real es ek run task deploy.
WHY
¿Por qué entry points de Python y no una carpeta de plugins?
Porque instalar ya es registrar. pipx inject eaglekit eaglekit-plugin-mirror y el comando existe; las dependencias del plugin las resuelve el empaquetado, no el CLI. Una carpeta ~/.eaglekit/plugins/ obligaría a inventar un formato, versionarlo y resolver imports a mano.
Profundizar

El contrato es mínimo a propósito: un grupo, eaglekit.plugins, y una función register(app). El core no conoce ningún plugin por nombre y no hay lista central que mantener. El coste es el reto 05: el namespace lo elige cada plugin.

WHY
¿Por qué el tracking de Git es un plugin y no parte del core?
El core es un gestor de proyectos. Quien solo registra rutas y lanza tareas no debería cargar hooks, cherry-picks ni una base de datos que nunca usará. La separación además impuso una disciplina: el plugin no importa nada del core; toda la comunicación es por convenciones del sistema de ficheros.
Profundizar

Cada plugin puede instalarse solo: declara eaglekit como dependencia y arrastra el core si no está. Los dos plugins públicos siguen Clean Architecture con capas domain, application, infrastructure y presentation: ARCHITECTURE.md del mirror.

WHY
JSON en los plugins públicos, SQLite en Flow: una migración real
Con JSON, cada hook hace leer-modificar-escribir sin bloqueo, una consulta cruzada abre dos ficheros y un push atómico toca tres. Flow los sustituye por un flow.db en .git/eaglekit/: modo WAL para las escrituras desde hooks, SQL con índices para las consultas y una transacción por cherry-pick. SQLite viene con Python: sin servidor, sin dependencia nueva.
Profundizar
Flow · SQLite
tracker + mirror · JSON
Ficheros de estado
1: flow.db
branches/*.json, links.json, sync_state.json y los .map heredados
Escritura desde hooks
WAL + transacción
leer-modificar-escribir sin bloqueo
Consulta cruzada
un SELECT con índice
abrir y cruzar dos ficheros
Handshake del cherry-pick
fila en la misma transacción
fichero de metadata con nombre acordado
Flow es la unificación de los dos plugins públicos y el que documenta el README del core (ek flow init · create · push · pull · rebuild). Su repositorio todavía no está publicado.
07 · Qué haría distinto

Cinco decisiones que hoy tomaría al revés

Hice
Un cli.py de 2 571 líneas con todo el core
Hoy haría
Módulos por responsabilidad: commands/projects.py, tasks.py, todos.py. Hoy cambiar la persistencia de los TODOs es una búsqueda en 2 500 líneas.
Hice
El registro como Dict[str, Any] sin tipar
Hoy haría
Un dataclass Registry con entidades, como ya tienen los plugins (CommitEntry, MirrorLink, SyncState). El core merece el mismo modelado que sus plugins.
Hice
Dos plugins hablando por ficheros con nombre acordado
Hoy haría
Un módulo compartido que sea dueño del nombre y del formato del handshake. Un string duplicado en dos repos se desincroniza; una constante importada no.
Hice
CREATE TABLE IF NOT EXISTS sin migraciones
Hoy haría
Una tabla schema_version y migraciones secuenciales. Sin eso, un flow.db existente nunca recibe columnas nuevas.
Hice
Hooks que solo se prueban con un repo Git real
Hoy haría
Que la lógica del hook reciba el servicio de Git y la conexión como parámetros. Hoy el bash con Python embebido no se puede testear unitariamente.
08 · Si te llevas tres cosas

Recap

1
Un solo comando ek que no conoce sus plugins: los descubre por entry points al arrancar, cada uno en su namespace, y un plugin roto solo produce un aviso.
2
Dos hooks de Git son dos procesos sin memoria común y con un humano entre ellos. Se coordinan por el sistema de ficheros, y el diseño tolera que el humano aborte.
3
El cherry-pick cambia el hash, así que la identidad de un commit es un UUID que viaja con él. Eso es lo que permite espejar QA → Prod sin aplicar nada dos veces.
Enlaces