51 Commits

Author SHA1 Message Date
egutierrez 588d092858 docs(infra): add .md metadata for write_xlsx_sheets function
The function code and its registry metadata were created together but the
.md was left untracked by the auto-commit. Add it so the indexed function
has its companion metadata versioned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:35:42 +02:00
egutierrez a90b7443e4 cuando termines y verifica que esté todo subido
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 01:33:35 +02:00
egutierrez e1e9bb7499 feat(shell): auto-commit con 31 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 23:55:16 +02:00
egutierrez 1430039688 feat(recon): modo CDP en fingerprint_web_stack para detectar SPAs
Añade fetch_http_fingerprint_cdp_py_browser (domain browser): recoge el HTML
renderizado tras ejecutar JavaScript usando un Chrome remoto via CDP, componiendo
cdp_open_url_and_wait + cdp_eval. Devuelve la misma estructura que el fetch
estático para que detect_web_tech lo consuma sin cambios.

Integra use_cdp en el pipeline fingerprint_web_stack (v1.1.0): combina los headers
reales del fetch estático con el HTML post-JS del CDP. Detecta frameworks de SPA
(React/Vue/Angular/Next) que el fetch estático no ve porque montan el DOM en
runtime. Si no hay Chrome en cdp_port, degrada al fetch estático con un warning
(no rompe). cdp_port=9333 (Chrome aislado) recomendado para terceros, 9222 diario.

Verificado en vivo (Chrome 9333): sobre una SPA cuyo marcador de framework solo
aparece tras ejecutar JS, el estático detecta solo nginx; con use_cdp=True detecta
además Next.js, React y Node.js.

Tests: 48 verdes (error path sin Chrome + happy path mockeado + degradación).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:31:28 +02:00
egutierrez 935008ec3f feat(recon): grupo de reconocimiento de red + servicios + fingerprint web
Añade el capability group `recon` (dominio cybersecurity + pipelines, Python),
con la política de archivado OSINT y página madre docs/capabilities/recon.md.

Lookups y sondeo (wrappers de CLI):
- whois_lookup, rdap_lookup, dns_records, ping_host, traceroute_host, nmap_scan
- save_scan_to_osint (sink común) + recon_osint (pipeline one-shot scan+archivado)

Escaneo de puertos/servicios nativo (stdlib, sin nmap ni sudo):
- scan_tcp_ports: connect-scan TCP concurrente (open/closed/filtered)
- grab_service_banner: banner grab + identificación de servicio/versión real
- identify_port_service: puro, puerto -> servicio IANA esperado (~120 puertos)
- scan_port_services: pipeline one-shot (scan -> identify + banner por puerto abierto)

Fingerprint de tecnología web (estilo Wappalyzer), patrón pura/impura:
- fetch_http_fingerprint: GET stdlib, recoge headers/html/cookies (solo nombres)
- detect_web_tech: puro, matchea ~50 firmas regex -> tecnologías por categoría
- fingerprint_web_stack: pipeline one-shot url -> tecnologías

Todas devuelven dict {status} sin lanzar. Tests: 43 verdes, sin red externa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:12:07 +02:00
egutierrez d89da1292d chore: auto-commit (9 archivos)
- docs/capabilities/INDEX.md
- docs/capabilities/obsidian.md
- python/functions/core/render_markdown_table.md
- python/functions/core/render_markdown_table.py
- python/functions/core/render_markdown_table_test.py
- python/functions/core/upsert_sentinel_block.md
- python/functions/core/upsert_sentinel_block.py
- python/functions/core/upsert_sentinel_block_test.py
- python/functions/infra/duckdb_query_readonly.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-13 21:56:56 +02:00
egutierrez 83f1d7c8d3 docs(browser): actualiza .md de cdp_wait_load/type_text/type_ref (evento, insertText, growth log)
Sincroniza la documentación con los cambios de comportamiento:

- cdp_wait_load.md: descripción y notas reflejan el cambio de polling a evento Page.loadEventFired con fast path; bump a v1.1.0; añade tag de grupo 'navegator' y growth log.
- cdp_type_text.md: corrige la nota (envía 2 eventos keyDown+keyUp, no 3; ya no manda el char extra que duplicaba) y la pausa aleatoria; documenta la función hermana rápida CdpInsertText; bump a v1.1.0; tag 'navegator'; growth log.
- cdp_type_ref.md: documenta CdpTypeRefFast (camino rápido insertText) frente a CdpTypeRef (camino human); bump a v1.1.0; growth log.
2026-06-13 14:27:17 +02:00
egutierrez 216cad4c12 perf(browser): acelera CDP — enable cacheado, wait_load por evento, timeout en sendCDP, escritura insertText
Optimiza el dominio browser para que el manejo del navegador via CDP sea mucho más rápido en automatización propia, manteniendo el camino sigiloso disponible.

- CDPConn cachea los enable de Accessibility/Network/Page por conexión (ensureAX/ensureNetwork/ensurePage): elimina un round-trip redundante en cada percepción y espera, que son las operaciones más frecuentes del bucle percibir->actuar del agente.
- sendCDP adquiere timeout (cdpCmdTimeout 30s): antes una respuesta que Chrome nunca enviaba colgaba la goroutine del tool indefinidamente; ahora falla limpio y el retry puede reconectar.
- CdpWaitLoad pasa de polling de document.readyState cada 200ms a esperar el evento Page.loadEventFired, con fast path inicial de readyState y re-chequeo anti-carrera tras suscribir. Si la página ya está cargada retorna en microsegundos.
- cdp_wait_idle usa ensureNetwork y deja de hacer Network.disable al salir (borraba el estado y forzaba el enable de nuevo).
- Nuevas funciones de escritura rápida: CdpInsertText (todo el texto en un solo Input.insertText) y CdpTypeRefFast (focus + insertText). El chequeo de foco se extrajo a assertEditableFocus, compartido con CdpTypeText.
- CdpTypeText pasa su pausa entre caracteres de 10ms fija a aleatoria 15-65ms (ritmo humano irregular).
- El modo 'auto' se añade al perfil de ratón (MouseProfileForMode, mouseHumanDefaults, clickPauseMs) como alias rápido de 'fast'.

No se tocan las firmas públicas existentes; CdpTypeRef y CdpTypeText conservan su comportamiento (camino human).
2026-06-13 14:27:10 +02:00
egutierrez 167a7e5eb7 feat(core): contact_import_key — clave de importacion determinista de contactos
Hash estable (tel normalizado > email > nombre normalizado) para importaciones
idempotentes: re-importar el mismo .vcf matchea la fila existente sin depender de
UIDs opacos ni de nombres que el pipeline de import transforma. Prefijo v1- para
versionar el algoritmo. Funcion pura + 5 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:47:48 +02:00
egutierrez b8ec97e477 fix(security): build_vcard neutraliza el retorno de carro crudo (anti CR-injection vCard)
El escape de valores vCard solo escapaba el salto de linea, no el retorno de
carro crudo. Un \r sin \n sobrevivia al escape y los parsers que lo normalizan
a salto de linea (como _unfold_lines de osint_web) leian propiedades inyectadas
(p.ej. X-OSINT-DNI), burlando el control de no exponer datos OSINT al movil.
Ahora _vcard_escape elimina el \r, en paridad con el escape iCal. Test de
regresion anadido.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:19:43 +02:00
egutierrez 40400c0b88 fix(security): duckdb_query_readonly sandbox por defecto (enable_external_access=false)
CRÍTICO: read_only=True protege la base de datos pero NO el sistema de ficheros. Un
SELECT con read_csv/read_blob/glob/COPY...TO podía leer ficheros arbitrarios (claves SSH)
o escribirlos (camino a RCE). Añadido parámetro sandbox (default True) que abre la conexión
con enable_external_access=false, bloqueando todo acceso a FS/red desde la query. Los SELECT
normales sobre tablas siguen funcionando. Único consumidor (osint_db /api/query) queda
protegido sin cambios. Tests nuevos: sandbox bloquea read_csv; sandbox=False lo permite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:21:01 +02:00
egutierrez 236a4740b0 fix(dav): error_type en dav_make_addressbook/dav_make_calendar (impure requiere error_type)
El indexer rechaza funciones impure con error_type vacío. Ambas funciones del grupo dav
declaran error_go_core como el resto de las funciones DAV Python del registry.
2026-06-13 00:45:00 +02:00
egutierrez 1c4a4b9259 feat(duckdb,dav): primitivas de escritura DuckDB + libretas CardDAV + vCard multi-valor
Cinco funciones nuevas para soportar DuckDB como fuente de verdad del project osint:

Grupo duckdb (escritura, complementan a duckdb_query_readonly):
- duckdb_execute_py_infra (impure): ejecuta INSERT/UPDATE/DELETE/DDL en read-write, commit, {status,rowcount}. 6 tests.
- duckdb_upsert_py_infra (impure): UPSERT ON CONFLICT actualizando solo update_cols → ownership selectivo (un re-upsert no pisa columnas excluidas). 7 tests.

Grupo dav (libretas de contactos + vCard multi-valor):
- dav_make_addressbook_py_infra (impure): crea una libreta CardDAV nueva via extended MKCOL (RFC 5689). Idempotente. 12 tests.
- dav_list_addressbooks_py_infra (impure): lista las libretas del contacts-home (PROPFIND Depth:1). 7 tests.
- build_vcard_py_core (pure): serializa un contacto a vCard 3.0 multi-valor (N TEL/EMAIL/ADR + X-OSINT-*). 5 tests.

Paginas de capacidad duckdb.md y dav.md actualizadas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:33:12 +02:00
egutierrez 1c8a86594f feat(dav): expand_rrule + dav_make_calendar para recurrencia y multi-calendario
Dos funciones nuevas del grupo de capacidad `dav`:
- expand_rrule_py_infra (pure): expande una RRULE iCalendar a las fechas de
  cada ocurrencia dentro de un rango [from, to]. Solo stdlib (datetime, re).
  Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL, BYDAY. 9 tests.
- dav_make_calendar_py_infra (impure): crea una coleccion de calendario nueva
  via MKCALENDAR + PROPPATCH de nombre/color. Idempotente si ya existe. 11 tests.

Consumidas por la app osint_web (eventos recurrentes + creacion de agendas).
Pagina del grupo dav actualizada con ambas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:30:01 +02:00
egutierrez a76760edba feat(dav,obsidian): grupo dav completo (CardDAV/CalDAV client + split vcf/ics + import pipelines) + build_obsidian_graph + dav_list_calendars
Funciones reutilizables creadas esta sesion para el sistema self-hosted de contactos/calendario (Xandikos) y la app osint_web:
- grupo dav (infra): split_vcards, split_vevents_to_vcalendars, extract_or_make_uid, carddav_put_vcard, caldav_put_event, dav_list_resources, dav_get_resource, dav_list_calendars
- pipelines: import_vcf_to_carddav, import_ics_to_caldav
- obsidian: build_obsidian_graph (grafo agregado del vault)
2026-06-12 00:43:59 +02:00
egutierrez 4a0f0e9dc0 feat(infra): dav_delete_resource — DELETE de recurso CardDAV/CalDAV (grupo dav)
Completa el CRUD del grupo dav (put/get/list/get-collection/delete). HTTP DELETE
con Basic auth, If-Match opcional para borrado condicional, maneja 404 como
idempotente. Solo stdlib. 7 tests deterministas (monkeypatch urlopen). Probado
contra Xandikos real durante la limpieza del ciclo de sync OSINT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:30:02 +02:00
egutierrez 73f41a3474 feat(dav): dav_get_collection + dav_collection_ctag — bulk DAV en 1 request + ctag cache
dav_get_collection trae TODOS los recursos de una coleccion CardDAV/CalDAV en
UNA peticion REPORT (addressbook-query / calendar-query) con el contenido vCard
/ VCALENDAR inline, evitando el patron N+1 (PROPFIND + un GET por recurso). Para
1064 contactos baja de ~9s a ~1s. dav_collection_ctag lee el ctag de la
coleccion (PROPFIND Depth:0 barato) para validar caches sin descargar cuando
nada cambio. Ambas: solo stdlib, basic auth, verify_tls, error-safe, tests que
mockean el multistatus. Grupo dav, verificadas contra Xandikos real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:07:39 +02:00
egutierrez eb8dbf66a1 feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 00:16:46 +02:00
egutierrez 6bc97df5c0 Merge quick/orquestador-command: /orquestador + grupo orchestration (launch_claude_agent_kitty, list_claude_agents) 2026-06-08 21:15:16 +02:00
egutierrez e769836b0d feat(pipelines): auto-commit con 1 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:33:13 +02:00
egutierrez 93756fbd0c chore: auto-commit (1 archivos)
- .claude/settings.local.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:28:02 +02:00
egutierrez 0a6d1b8d17 feat(infra): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 01:57:00 +02:00
egutierrez 82f1f1bd58 feat(infra): parse_unibus_health — healthz del cluster unibus → []PromSample
Función del grupo fleet-metrics que convierte la respuesta JSON del endpoint /healthz
de un nodo unibus (membershipd) en series Prometheus (unibus_up, unibus_status_ok,
unibus_posture_enforce/acl/tls/cluster, unibus_store_kv) con labels node/instance.
Pura de transformación (impure solo por el error de unmarshal). La consume el daemon
unibus_exporter del project fleet_monitoring. Con tests golden/edge/error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:26:15 +02:00
egutierrez 9a9b876400 feat(commands): /orquestador — modo de coordinacion de Claudes secundarios en kitty
Nuevo slash command que codifica el modo orquestador: el Claude principal
descompone una tarea grande y lanza Claudes secundarios interactivos, cada uno
en su propia terminal kitty con un prompt autonomo inyectado y aislamiento git
impuesto (worktree / sub-repo / scope disjunto). El humano habla solo con el
orquestador, ve a los secundarios en sus terminales y puede saltar a cualquiera.

El cuerpo cubre los 8 pasos del ciclo (descomponer, lanzar, aislar, prompt,
seguir, no pkill, integrar, kitty vs Agent tool), la plantilla del comando de
lanzamiento, la tabla de seguimiento de la flota, las reglas de aislamiento, los
anti-patrones y un ejemplo end-to-end. Referencia las funciones del registry
launch_claude_agent_kitty_bash_infra, list_claude_agents_bash_infra y
reboot_all_claudes_bash_infra (grupo orchestration). Deja explicita la diferencia
con fn-orquestador / autopilot (Agent tool en sandbox no-interactivo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:32:47 +02:00
egutierrez 5c253a26e2 feat(infra): grupo orchestration — launch_claude_agent_kitty + list_claude_agents
Dos funciones bash para la mecanica del modo orquestador (Claudes secundarios
interactivos en kitty):

- launch_claude_agent_kitty(title, directory, prompt_file): lanza un Claude Code
  secundario en su propia terminal kitty con un prompt autonomo inyectado y
  --dangerously-skip-permissions, detached (setsid nohup ... disown) para
  sobrevivir al cierre de la terminal padre.
- list_claude_agents([--json] [--exclude-current]): lista la flota de Claudes
  vivos cruzando pgrep -x claude con ~/.claude/sessions/<PID>.json (con
  validacion anti-PID-reciclado por procStart), reportando PID, sessionId, cwd,
  status, etime y KITTY_PID. Reusa la logica de descubrimiento de
  reboot_all_claudes_bash_infra.

Tag de grupo de capacidad: orchestration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:32:33 +02:00
egutierrez 10bfb846a8 ahora si funciona
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 16:23:52 +02:00
Egutierrez d996542f88 feat(infra): grupo fleet-metrics — collect_host_metrics, format_prom_exposition, push_prom_remote, push_loki_stream, collect_battery_metrics + tipo PromSample (gopsutil; Android-safe: sin exec/pidfd, procesos via /proc) 2026-06-07 14:25:45 +02:00
egutierrez 8742cb25be feat(browser): auto-commit con 60 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:42:31 +02:00
egutierrez 37aacfcfa9 feat(browser): chrome_launch ReuseExisting — guarda anti-duplicado de Chrome
Añade el campo ReuseExisting a ChromeLaunchOpts. Con ReuseExisting=true, si el
puerto CDP ya responde a una conexión TCP, ChromeLaunch NO lanza un Chrome nuevo
y devuelve (0, nil) para que el caller se adjunte al existente. Evita acumular
procesos chromium duplicados en el mismo puerto (cada uno ~789 MiB RSS), causa
del leak de RAM del browser_mcp.

Extrae el sondeo de puerto a dialCDP/cdpPortResponds (net.Dial con timeout), que
waitCDPReady ahora reutiliza en su bucle. Tests sin Chrome real (TestCdpPortResponds,
TestChromeLaunchReuseExisting) usando un net.Listener local como puerto ocupado.
Bump a 1.4.0 + growth log + gotchas en el .md (pid 0 = no es nuestro, no matar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:06:45 +02:00
egutierrez 029dbf57bd feat(core): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 13:20:36 +02:00
egutierrez 3f6b652f3f chore(agents): subir los 6 agentes fn de sonnet a opus
Los agentes del ciclo reactivo (constructor, executor, recopilador,
analizador, mejorador, orquestador) corrian con model: sonnet. Se suben
todos a model: opus para mejorar la calidad del codigo generado y del
razonamiento durante el ciclo CONSTRUIR -> EJECUTAR -> RECOPILAR ->
ANALIZAR -> MEJORAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:17:46 +02:00
egutierrez 5b10b419a2 feat(browser): auto-commit con 44 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 12:49:54 +02:00
egutierrez e2c073b8b7 feat(browser): set_chrome_profile_appearance v1.1.0 — color tiñe el tema del navegador
Antes --color solo escribía los campos de color en Local State (info_cache), que
únicamente tiñen el círculo del avatar en el selector de perfiles. Ahora --color
aplica además el tema del navegador (toolbar, frame/bordes, barra de pestañas y
omnibox), que es lo que permite identificar un perfil de un vistazo.

El tema vive en el Preferences del perfil, no en Local State. La función ahora
escribe browser.theme.user_color2 (SkColor ARGB con signo), browser_color_variant
y is_grayscale2, y fuerza extensions.theme.system_theme=0. Escribe también las
claves legacy sin sufijo "2" por compatibilidad de versiones. Nuevo flag
--variant <0..4> (default 3 vibrant) para la intensidad del tinte. Backup y
validación del Preferences con el mismo patrón que Local State.

Claves verificadas empíricamente con captura de pantalla en Chromium 148: un
perfil lanzado con estas claves muestra la toolbar y el frame teñidos del color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:12:37 +02:00
egutierrez 25054ff64e feat(browser): set_chrome_profile_appearance — avatar + color de perfiles Chrome
Nueva función Bash del dominio browser para personalizar la apariencia de un
perfil Chrome/Chromium y diferenciarlo de un vistazo. Edita
`profile.info_cache.<perfil>` en el Local State del user-data-dir:

- `--avatar <N>`: avatar built-in de Chrome (índice 0..55) vía
  `avatar_icon = chrome://theme/IDR_PROFILE_AVATAR_<N>`. Camino robusto.
- `--avatar <ruta.png>`: avatar custom best-effort (copia la imagen al perfil y
  marca `is_using_default_avatar=false`); ver gotchas del .md.
- `--color <#rrggbb>`: color del perfil. Convierte el hex a int32 con signo en
  formato ARGB (0xAARRGGBB) y lo aplica a `profile_highlight_color`,
  `profile_color_seed` y `default_avatar_fill_color`.

Sigue el patrón de create/delete_chrome_profile: backup del Local State antes de
escribir, validación del JSON resultante con restauración del backup si queda
inválido, guard de SingletonLock (chromium debe estar cerrado), idempotente y
con --dry-run. No crea perfiles (eso es create_chrome_profile); requiere que el
perfil ya exista. Probada con --avatar 26 --color #1f6feb y casos edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:57:12 +02:00
egutierrez 648ce63fc0 chore: auto-commit (1 archivos)
- .claude/settings.local.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 01:38:36 +02:00
Egutierrez 685224ccb2 fix(browser): guards chromium-por-udd dejan de auto-matchear el propio grep
Bug descubierto al ejecutar el reset real: los guards y los kills usaban
'pgrep -af [c]hromium | grep -F <udd>'. Como la ruta del user-data-dir contiene
la cadena 'chromium' (~/.config/chromium-cdp), el propio proceso grep/ugrep —cuyo
cmdline incluye <udd>— era capturado por pgrep, dando un falso positivo perpetuo:
el guard creía siempre que había un chromium abierto y delete/restore abortaban
con exit 2, y el lazo de cierre nunca convergía.

Fix en delete_chrome_profile, restore_chrome_bookmarks, create_chrome_profile y el
pipeline reset_chrome_profiles: enumerar por PID con 'pgrep -x chromium' (comm
exactamente 'chromium', nunca grep/pgrep/bash) y leer /proc/PID/cmdline para
comprobar el udd. Validado: reset destructivo real de los 4 perfiles completó OK,
cada perfil quedó con solo uBlock + web_proxy y los bookmarks restaurados.
2026-06-06 01:37:51 +02:00
Egutierrez ae841ceedb feat(browser): CRUD de perfiles Chromium + pipeline reset_chrome_profiles
Cinco funciones nuevas (dominio browser, grupo navegator) que cierran los gaps
de gestión de perfiles, más un pipeline que las orquesta:

- backup_chrome_bookmarks / restore_chrome_bookmarks: backup y restore de los
  archivos Bookmarks (copia byte a byte verbatim para preservar el checksum
  interno; en Chromium 148 los bookmarks no están bajo el super_mac de Secure
  Preferences). Guard por user-data-dir (no global).
- delete_chrome_profile: borra la carpeta del perfil + limpia su entrada en
  Local State (info_cache, profiles_order, last_active_profiles, last_used).
- create_chrome_profile: lanza chromium headless (vía systemd-run) para que la
  managed policy instale la whitelist de extensiones, y asigna el nombre legible
  en Local State. Mata todo el árbol de chromium del udd antes de editar Local
  State (los hijos zygote/gpu no repiten --user-data-dir pero referencian la ruta).
- list_chrome_profile_extensions (Go): lista extensiones de un perfil con
  ID/name/version/location/enabled/fromPolicy. 7 unit tests.
- reset_chrome_profiles (pipeline): backup -> cerrar chromium -> delete -> create
  -> restore -> verify. Destructivo (--yes), --dry-run seguro.

Validado: unit tests Go verdes, backup/restore byte-idéntico, delete limpia Local
State, create instala la forcelist global (uBlock + web_proxy) en perfiles nuevos.
2026-06-06 01:24:21 +02:00
egutierrez 736e019e19 feat(core): auto-commit con 17 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 17:34:22 +02:00
Egutierrez 1f93e9d502 docs: projects son sub-repos Gitea (dataforge/<name>)
Cada projects/<name>/ es ahora su propio repo Gitea con branch master,
versionando solo sus docs de nivel-project. apps/*/ y analysis/*/ siguen
como sub-repos hijos independientes (excluidos por el .gitignore del project).
/full-git-push|pull los manejan via discover_git_repos. Cierra el gap de
docs de nivel-project sin versionar. Aplicado a web_scraping, fn_monitoring,
message_bus.
2026-06-05 17:30:54 +02:00
Egutierrez b75bd7e154 feat(browser): apply_chromium_extension_policy soporta --keep id=update_url
Permite force-instalar extensiones self-hosted bajo managed policy indicando
un update_url propio (p.ej. file:// a un update.xml local que apunta a un .crx).
Necesario para cargar extensiones propias (como la de captura de web_proxy)
cuando hay una managed policy activa y --load-extension queda desactivado en
Chromium 137+. Forma simple '<id>' sigue usando el update_url por defecto.
2026-06-05 17:22:20 +02:00
Egutierrez e0fad0e82f feat(browser): clean_chrome_profile_extensions + fix policy backup en managed/
Rediseño de apply_chromium_extension_policy y nueva función de purga in-place,
tras resolver por qué las extensiones bloqueadas reaparecían en Chromium 148.

- apply_chromium_extension_policy: añade --block (ExtensionInstallBlocklist).
  Reemplaza el modo ExtensionSettings "*": blocked (que rompía las extensiones
  unpacked vía --load-extension, p.ej. la de captura de web_proxy con el error
  'Loading of unpacked extensions is disabled by the administrator') por una
  blocklist específica. FIX RAÍZ: los backups se guardan fuera de policies/managed/
  (en policy-backups/), porque Chromium lee TODOS los archivos del directorio
  managed/ sin filtrar extensión de nombre — un extensions.json.bak ahí se mergea
  con la policy y reinyecta las extensiones del backup (location=7).
- clean_chrome_profile_extensions (nueva): purga in-place de un perfil existente
  (borra carpetas de Extensions/ + refs en Preferences/Secure Preferences) dejando
  solo la whitelist. Complementa la policy: la policy evita reinstalación, esta
  desinstala lo ya presente. Requiere chromium cerrado.

Ambas: dominio browser, grupo navegator, guard de auto-ejecución, dry-run.
2026-06-05 17:13:49 +02:00
Egutierrez 830f2d34de feat(browser): funciones idempotentes para config de sistema de chromium
Cierra el gap de reproducibilidad entre PCs del proyecto web_scraping:
la organizacion de extensiones y el CDP global dejaban de ser pasos
manuales con sudo documentados en prosa.

- apply_chromium_extension_policy: escribe ExtensionInstallForcelist
  (whitelist via --keep) en /etc/chromium/policies/managed/extensions.json
  de forma idempotente, con backup automatico y validacion JSON. --dry-run
  previsualiza sin tocar el sistema.
- apply_chromium_cdp_flag: gestiona /etc/chromium.d/cdp (CDP global).
  Loopback por defecto, --network para bind 0.0.0.0 (con aviso), --remove
  para desactivar, --dry-run para previsualizar. Idempotente con backup.

Ambas: dominio browser, grupo navegator, impuras (escriben en /etc via
sudo), guard de auto-ejecucion (ejecutables con fn run y sourceables).

Docs del proyecto (CONVENTIONS.md reglas 8/9, CHROMIUM_SYSTEM.md
inventario + tabla accionable) ahora apuntan a 'fn run apply_chromium_*'
como metodo canonico en vez de editar los archivos de /etc a mano.
2026-06-05 16:33:35 +02:00
Egutierrez ccfa5bc78b feat(browser): funciones anti-deteccion + perfiles para web_scraping
Funciones nuevas del dominio browser (grupo navegator):
- cdp_move_mouse_human / cdp_click_human: movimiento de raton con curva
  de Bezier cubica, easing y micro-jitter para imitar comportamiento
  humano y reducir deteccion de automatizacion.
- cdp_wait_idle: espera network-idle contando requests en vuelo via
  eventos CDP Network.*; inmune a extensiones que mutan el DOM
  (Dark Reader, uBlock) y a animaciones JS.
- list_chrome_profiles: lista perfiles de un user-data-dir (extensiones,
  nombre legible, preferencias).
- prepare_chrome_profile (bash): clona un user-data-dir conservando solo
  una whitelist de extensiones (default uBlock Origin Lite).

Modificadas:
- chrome_launch: Linux-first (chromium/google-chrome/brave antes que
  chrome.exe), KeepExtensions y Setpgid para matar el arbol con cdp_close.
- cdp_close: kill por grupo de proceso.

Todas con tests verdes (go test ./functions/browser ok).
2026-06-05 16:25:11 +02:00
egutierrez 729921e16e feat(cybersecurity): auto-commit con 48 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 23:44:39 +02:00
egutierrez efc9911925 feat(kotlin-compose): design system fn.compose:ui + toolbelt android Linux-first
Design system Compose (kotlin/functions/ui, modulo Gradle `fn.compose:ui`):
- FnTokens + FnTheme con la paleta heredada al hex de cpp/DESIGN_SYSTEM.md
  (Mantine v9 dark + indigo), identica a la web @fn_library y a las apps C++.
- 26 componentes Compose (Layout/Display/Inputs/Feedback/Data/Charts) +
  FnTheme + FnTokens registrados en el registry (28 entradas kind=component
  lang=kt domain=ui), descubribles via fn_search. Habilitan init_kotlin_app.

Recuperacion: el commit cb6d9e6 habia anadido `kotlin/functions/ui/` al
.gitignore, por eso el design system nunca se versiono y se perdio del working
tree. Des-ignorado; el .gitignore interno del modulo ya excluye
build/.gradle/local.properties. La gallery (apps/gallery_kt) se recupero del
sub-repo Gitea y sus 27 componentes se reconstruyeron con su MainActivity como
contrato exacto.

Toolbelt Android Linux-first (antes asumia WSL2 + Windows):
- adb_wsl 1.1.0, android_emulator_start 1.1.0, android_emulator_list 1.1.0:
  resuelven adb/emulator nativos del SDK ($ANDROID_HOME), .exe solo fallback WSL2.
- android_emulator_start: fix `timeout adb_run wait-for-device` (timeout no puede
  ejecutar una funcion del shell; ahora invoca el binario $ADB directamente).
- install_android_sdk 1.0.1: fix licencias bajo pipefail (SIGPIPE de `yes`) +
  trap EXIT con variable unbound.
- docs/capabilities/android.md regenerado Linux-first + seccion design system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:43:59 +02:00
egutierrez c65f1698ae fix(infra): write_mcp_jupyter_config usa wrapper jupyter_mcp_serve con el venv del analysis
El .mcp.json generado ahora apunta al wrapper jupyter_mcp_serve.sh con env overrides
(JUPYTER_MCP_VENV/ROOT/PORT/TOKEN) en vez del console-script jupyter-mcp-server directo.

Antes: el .mcp.json solo CONECTABA a un Jupyter ya existente y, si se abria Claude
desde la raiz del repo, el MCP usaba el venv canonico python/.venv (sin las deps del
analisis). Ahora el wrapper arranca (o reusa) un Jupyter con el venv del propio
analisis, asi que abrir Claude desde el directorio del analisis basta y cada analisis
ejecuta con sus dependencias sin contaminar python/.venv.

Bump v1.2.0. Declara dependencia jupyter_mcp_serve_bash_infra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:48:01 +02:00
egutierrez 516db8efc0 feat(infra): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 16:56:53 +02:00
egutierrez fa09ff9866 feat(infra): auto-commit con 4 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 16:44:23 +02:00
egutierrez 6aec0413bb feat(infra): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 16:16:36 +02:00
egutierrez ea6a3ec8a5 chore: auto-commit (3 archivos)
- docs/adr/README.md
- docs/adr/0005-keep-parent-git-lean.md
- docs/diary/2026-06-03.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 12:50:09 +02:00
Egutierrez 3c1061fbd8 feat(core): dag_parse parsea continue_on.exit_code + retry_policy (v1.1.0)
DagContinueOn gana el campo ExitCodes []int (codigos de salida tolerados) y el
parser mapea continue_on.exit_code desde el YAML. retry_policy (limit,
interval_sec) ya existia en el modelo y ahora queda documentado como contrato
estable para los executors.

Funcion pura: solo normaliza el YAML al modelo DagDefinition; la interpretacion
(reintentar, tolerar codigos) vive en el executor que lo consuma (dag_engine).

Test: 'parsea continue_on.exit_code y retry_policy'. Tag de grupo: scheduler.
2026-06-03 12:44:26 +02:00
627 changed files with 58942 additions and 631 deletions
+8 -3
View File
@@ -21,9 +21,11 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
**Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token).
**Sub-repos:** cada app y cada analysis es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*` y `analysis/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). Ver `.claude/rules/apps_subrepo.md`.
**Sub-repos:** cada app, cada analysis y **cada project** es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*`, `analysis/*` y `projects/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Cada `projects/<name>/` es a su vez un sub-repo que versiona solo sus docs de nivel-project (`project.md`, `CONVENTIONS.md`, ...) con un `.gitignore` interno que excluye `apps/*/` y `analysis/*/` (sub-repos hijos). Ver `.claude/rules/projects.md`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). **REGLA DURA**: el repo padre NUNCA trackea contenido de artefactos hijos (apps/analysis/projects) — solo `.gitkeep`. Nada de `git add -f` sobre esos paths: deja el padre permanentemente dirty (doble-tracking). Auditoria + fix en `.claude/rules/apps_subrepo.md`. Ver `.claude/rules/apps_subrepo.md`.
**Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`.
**Artefactos:** termino paraguas para apps, analysis, vaults, projects, playgrounds y reports — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md`, `.claude/rules/playgrounds.md` y `.claude/rules/reports.md`.
**Reports:** reportes de trabajo (entregable de una tarea: resumen + cambios + verificacion con evidencia + gaps). Son **artefacto local**: viven en `reports/` o `projects/<p>/reports/`, estan gitignored (salvo `reports/.gitkeep`), NO suben a Gitea ni se versionan en el padre y NO se indexan — igual que los vaults/playgrounds. Compartir = pasar la ruta del `.md`. Convencion + plantilla en `.claude/rules/reports.md`. Decision: ADR 0006.
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
@@ -148,7 +150,7 @@ Cualquier `SELECT ... FROM functions/types/apps/proposals WHERE ...` plano se ha
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, obsidian
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
- Enums: `algebraic`(product|sum)
@@ -193,6 +195,7 @@ Regla decisiva: antes de cada bloque de codigo, decide caso. Si dudas entre 2 y
| `client._http.request(...)` directo cuando hay wrapper en el registry | Salta validacion del wrapper y telemetria | Usar wrapper; si la firma no cubre el caso, proponer extension via `fn proposal add` |
| Scripts en `temp/` para composiciones que se repiten | Codigo se pierde y no se monitoriza | Pipeline en `python/functions/pipelines/` o pipeline Bash en `bash/functions/pipelines/` |
| Imports `from <pkg> import *` en heredoc | Imposible saber que funcion del registry se uso | Imports explicitos `from <domain> import <name1>, <name2>` |
| `claude -p` o `subprocess(["claude", "-p", ...])` para obtener una respuesta del modelo | Lento (cold start ~7-15s, carga MCP + CLAUDE.md), caro, sin control de tools | `ask_llm` (grupo `claude-direct`, API directa, arranque 0). Ver regla `llm_invocation.md` |
Excepciones autorizadas para `sqlite3` directo (no requieren MCP): `.schema`, `.tables`, `PRAGMA table_info`, `COUNT(*) GROUP BY`, JOINs custom entre tablas que el MCP no expone.
@@ -230,6 +233,8 @@ fn-registry/
docs/ # Specs de diseño
docs/templates/ # Plantillas de frontmatter
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
reports/ # Reportes de trabajo (artefacto local: gitignored salvo .gitkeep, no Gitea, no indexado)
projects/*/reports/ # Reportes de un proyecto concreto (mismo trato: gitignored, local)
<artefacto>/playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa
```
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-analizador
description: "Agente analizador (Fase 4) del ciclo reactivo. Lee `e2e_checks` declarados en app.md, ejecuta la suite via `e2e_run_checks_go_infra`, evalua assertions activas, calcula drift de metricas vs historico, persiste resultado en `e2e_runs` de operations.db y devuelve veredicto caveman pass/fail. NO modifica codigo ni propone fixes — eso es trabajo de fn-mejorador (Fase 5)."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-constructor
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-executor
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-mejorador
description: "Agente mejorador (Fase 5) del ciclo reactivo. Lee resultados fallidos de fn-analizador desde `e2e_runs`/`assertion_results`, busca contexto en el registry, y crea proposals con evidencia trazable. NO modifica codigo: solo abre proposals para que un humano (o el bucle autonomo del issue 0069) decida."
model: sonnet
model: opus
tools: Read, Bash, Grep, Glob
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-orquestador
description: "Meta-orquestador (Fase 6) del ciclo reactivo. Toma un issue o task_spec y recorre CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR despachando a fn-constructor/executor/recopilador/analizador/mejorador hasta convergencia, estancamiento, timeout o tope de iteraciones. Trabaja SIEMPRE en rama sandbox `auto/<issue>`, NUNCA mergea a master, persiste progreso en `task_runs`. Issue 0069."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-recopilador
description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta. Modo extra `design-e2e <app_id>`: propone bloque `e2e_checks` para que la fase 4 (fn-analizador) pueda validar la app sin iteracion humana."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+22 -6
View File
@@ -173,23 +173,39 @@ Si el build falla:
- "undefined reference to render" → falta quitar `static` o falta el `#ifndef FN_TEST_BUILD` en main.cpp.
- "multiple definition of main" → falta el `target_compile_definitions(... FN_TEST_BUILD)` en CMakeLists.
### 8. Ejecutar (headless en WSL)
### 8. Ejecutar (headless preferente — sin parpadeo)
WSL no tiene GLX 4.3 nativo — los tests corren bajo `xvfb` con software renderer Mesa. Wrapper canonico:
`fn::run_app_test` crea la ventana GLFW **oculta por defecto** (`GLFW_VISIBLE=FALSE`, ver `cpp/framework/app_base.cpp`). El contexto GL real se crea igual, así que el render que ejercita el Test Engine es fiel, pero la ventana nunca se mapea en pantalla: cero parpadeo, no roba foco. Por eso los tests de frontend C++ corren headless por defecto, sin tocar el código de cada app.
Dos formas de lanzar, según el entorno:
```bash
cd "$ROOT/cpp/build/linux_tests"
TEST_BIN="$(find . -name "${APP_ARG}_tests" -type f -executable | head -1)"
[ -z "$TEST_BIN" ] && { echo "no encuentro el binario de tests"; exit 1; }
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
"$TEST_BIN" 2>&1
if [ -n "$DISPLAY" ] && command -v glxinfo >/dev/null 2>&1 \
&& glxinfo 2>/dev/null | grep -q "OpenGL core profile version"; then
# Host con GL nativo (PC enmanuel, X11 + GPU): binario directo.
# La ventana ya nace oculta -> sin parpadeo, y usa la GPU real (rapido).
timeout 90 "$TEST_BIN" 2>&1
else
# CI / WSL sin GLX 4.3 nativo: display virtual en RAM + software Mesa.
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
"$TEST_BIN" 2>&1
fi
EXIT=$?
echo "EXIT: $EXIT"
```
Si en el host el usuario tiene GL nativo y `DISPLAY` funciona, el wrapper xvfb-run sigue siendo seguro (ejecuta dentro de su propio display).
Ambas vías son headless. `xvfb-run` sigue siendo seguro en host con display (corre en su propio display virtual), así que si el sniff de GL falla puedes usar siempre la rama xvfb.
**Para depurar un test a ojo** (ver la UI mientras el engine la maneja), desactiva el headless con `FN_HEADLESS=0`:
```bash
FN_HEADLESS=0 timeout 90 "$TEST_BIN" 2>&1
```
### 9. Reportar
+159
View File
@@ -0,0 +1,159 @@
---
description: "Modo launcher: das ordenes en lenguaje natural y Claude responde SOLO con la procedencia (registry/bash/heredoc) + el comando exacto, y lo ejecuta. Agiliza el lanzamiento de comandos y audita en vivo el Reg % (uso real de funciones del registry)."
---
# /modo_launcher — lanzamiento rápido registry-first
Activa un **modo de comportamiento** persistente. Mientras estás dentro, el usuario da órdenes en lenguaje natural y Claude responde con el **mínimo absoluto**: la procedencia del comando + el comando exacto + por qué, y lo ejecuta. Sin prosa, sin explicaciones largas, sin preámbulos.
El objetivo es doble:
1. **Agilizar** el lanzamiento de comandos (cero verborrea entre orden y ejecución).
2. **Auditar en vivo** que de verdad pasamos por funciones del registry antes que por bash inline — sube `Reg %` (objetivo 1 del Norte) y expone gaps reutilizables (objetivo 3).
## Activación
Al invocar `/modo_launcher` entras en **MODO LAUNCHER**. El modo permanece activo en todos los turnos siguientes hasta que el usuario escriba `salir` o `fin launcher`. No hay hook: el modo se sostiene por estas instrucciones mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el usuario puede re-invocar `/modo_launcher` para reanclarlo.
Al entrar, responde con una sola línea de confirmación y queda a la espera:
```
MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
```
## Comportamiento por orden (regla dura)
Para CADA orden del usuario mientras el modo esté activo:
1. **Registry-first.** Mapea la orden a una capacidad y busca primero en el registry vía FTS (`mcp__registry__fn_search`) o reconoce un ID conocido. Las funciones del registry SIEMPRE tienen prioridad sobre bash inline.
2. **Clasifica la procedencia** según la taxonomía de abajo.
3. **Ejecuta directo.** Identificado el comando, ejecútalo sin pedir permiso — salvo que sea destructivo (ver guarda).
4. **Responde en el formato fijo** (abajo), con la salida cruda del comando. Nada más.
## Formato de respuesta (OBLIGATORIO en cada orden)
```
FUENTE: <etiqueta>
CMD: <comando exacto>
WHY: <razón: match FTS, ID conocido, o "sin función → bash">
──────────
<salida cruda del comando>
```
- `FUENTE` es una de las etiquetas de la taxonomía.
- `CMD` es el comando literal lanzado (forma `./fn run <id> [args]` para legibilidad aunque la ejecución real vaya por MCP).
- `WHY` es una línea: qué match de búsqueda o qué ID justifica esa elección. Si fue un gap, dilo.
- Tras la regla `──────────`, la salida cruda. Cero comentario después salvo que el usuario pregunte.
## Taxonomía de procedencia
| Etiqueta | Qué es | Cómo se ejecuta |
|---|---|---|
| `registry-run` | Ejecutar UNA función o pipeline del registry | `mcp__registry__fn_run <id> [args]` (preferido); fallback `./fn run <id> [args]` |
| `registry-mcp` | Inspeccionar el registro (buscar, ver, código, deps, dominios) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` |
| `heredoc` | Componer N funciones con lógica intermedia (loops, dispatch) | Heredoc `python/.venv/bin/python3 - <<'PY' ... PY` importando del registry |
| `bash` | Comando shell puro: no existe función que lo cubra | Bash directo |
| `gap` | No hay función Y el patrón parece reutilizable | Ejecuta el bash equivalente y marca el candidato (ver abajo) |
### Preferencia de ejecución para `registry-run`
- Usa `mcp__registry__fn_run` cuando esté disponible (queda registrado en `call_monitor`, alimenta el bucle reactivo).
- Si el MCP `fn_run` no está habilitado (requiere `--enable-run`), cae a `./fn run <id>` por terminal. La línea `CMD` muestra siempre la forma `./fn run <id>` por legibilidad.
## Gaps: orden sin función en el registry
Cuando una orden no tenga función que la cubra:
1. Ejecuta el bash equivalente (`FUENTE: bash`).
2. Si el patrón parece **reutilizable** (firma genérica, se repetiría en otras tareas, ≥5 líneas de lógica), añade tras la salida UNA línea:
```
CANDIDATO → <nombre_propuesto>_<lang>_<domain>: <1 frase de qué haría>
```
No lances `fn-constructor` automáticamente dentro del modo (rompería el ritmo de lanzamiento). Solo marca. El usuario decide al salir si promueve los candidatos.
## Guarda de comandos destructivos
Ejecuta directo SALVO que el comando sea irreversible o de alto impacto. En esos casos, NO ejecutes: muestra el bloque con `FUENTE`/`CMD`/`WHY` y añade `⚠ DESTRUCTIVO — confirma con 'ok'` en vez de la salida. Espera el `ok` explícito del usuario antes de lanzar.
Patrones que exigen confirmación:
- `rm -rf`, borrado de archivos versionados, `> archivo` sobre archivos trackeados.
- `git push --force`, `git reset --hard`, `git clean`, borrado de ramas.
- SQL `DROP`, `DELETE` sin `WHERE`, `TRUNCATE`, borrar cualquier `.db`.
- `deploy`, `systemctl stop/restart/disable` de services, `fn sync` (escribe en el servidor).
- `kill -9` masivo, `format`, `mkfs`, `dd`, cambios en `fstab`.
Para todo lo demás (lecturas, búsquedas, `fn run` de funciones puras o idempotentes, `git status/add/commit`, listados), ejecuta directo.
## Salida del modo
Cuando el usuario escriba `salir` o `fin launcher`, cierra el modo con un resumen caveman de una tabla:
```
=== fin MODO LAUNCHER ===
ordenes: N
registry: X (run A / mcp B)
bash: Y
gaps: Z → [lista de candidatos marcados]
Reg %: X/(X+Y) de las ordenes ejecutables golpearon el registry
```
Si hubo candidatos a función (`gap`), recuérdalos y pregunta si promover alguno vía `fn-constructor`.
## Reglas duras del modo
- **Registry-first siempre.** Nunca escribas bash inline para lógica que ya es función del registry. Busca antes de teclear.
- **Cero prosa fuera del bloque.** Nada de "Claro, voy a…". Solo el bloque `FUENTE/CMD/WHY/salida`.
- **Una orden = un bloque.** Si la orden necesita varios pasos, encadénalos en un solo `CMD` o numera los bloques, pero mantén el formato.
- **No inventes IDs.** Si no encuentras función por búsqueda, es `gap`, no un `registry-run` falso.
- **El modo no exime de las reglas del repo** (`registry_calls.md`, `purity.md`, etc.). Solo cambia el estilo de respuesta.
## Ejemplo de sesión
```
tú: /modo_launcher
yo: MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
tú: busca funciones de slice
yo: FUENTE: registry-mcp
CMD: mcp__registry__fn_search query="slice"
WHY: inspección directa del registro
──────────
filter_slice_go_core — Filtra slice por predicado
chunk_slice_go_core — Parte slice en lotes de N
...
tú: mata lo que escuche en el puerto 8484
yo: FUENTE: registry-run
CMD: ./fn run port_kill_bash_infra 8484
WHY: match FTS 'port kill' → port_kill_bash_infra
──────────
killed pid 33120 on :8484
tú: enséñame el uso de disco de home
yo: FUENTE: bash
CMD: du -sh /home/enmanuel/* | sort -rh | head
WHY: sin función → utilidad de sistema puntual
──────────
12G /home/enmanuel/fn_registry
...
CANDIDATO → disk_usage_top_bash_shell: top-N directorios por tamaño en una ruta
tú: salir
yo: === fin MODO LAUNCHER ===
ordenes: 3
registry: 2 (run 1 / mcp 1)
bash: 1
gaps: 1 → disk_usage_top_bash_shell
Reg %: 2/3 (67%)
1 candidato marcado. ¿Promuevo disk_usage_top_bash_shell vía fn-constructor?
```
## Relación con otras reglas
- `registry_calls.md` — el modo es una capa de estilo sobre los tres patrones canónicos (inspect / run / compose).
- `registry_first.md` — el modo materializa "buscar antes de escribir" en cada orden.
- `function_growth_and_self_docs.md` — los candidatos marcados alimentan la promoción de patrones inline a funciones.
- `kiss.md` — sin hook, sin estado en disco: el modo vive solo en estas instrucciones.
+279
View File
@@ -0,0 +1,279 @@
---
name: orquestador
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal kitty con un prompt autonomo y aislamiento git impuesto. El humano habla solo con el orquestador, ve a los secundarios en sus kitties y puede saltar a cualquiera. El orquestador sigue la flota, lee sus reports e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
---
# /orquestador — coordinar Claudes secundarios interactivos en kitty
Activa un **modo de comportamiento** persistente. Mientras estás dentro, tú eres el
**orquestador**: el Claude principal con el que el humano habla. Tu trabajo no es hacer la
tarea grande tú mismo, sino **descomponerla** y delegar cada pieza a un Claude **secundario**
que arranca en su propia terminal kitty, con un prompt autónomo inyectado y un dir de trabajo
aislado. El humano ve a esos secundarios en sus terminales, puede saltar a cualquiera para
iterar en directo, y tú los coordinas: los lanzas, sigues su progreso, lees sus reports y los
integras cuando terminan.
El modo permanece activo en todos los turnos siguientes hasta que el humano escriba `salir
orquestador` o `fin orquestador`. No hay hook: el modo se sostiene por estas instrucciones
mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el humano puede
re-invocar `/orquestador` para reanclarlo.
Al entrar, responde con una sola línea de confirmación y queda a la espera de la tarea grande:
```
MODO ORQUESTADOR activo. Dame la tarea grande; la descompongo y lanzo secundarios. 'fin orquestador' para terminar.
```
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
Hay dos cosas con nombre parecido. No las confundas:
| | **Modo orquestador** (este comando) | **`fn-orquestador`** (subagent / `/autopilot`) |
|---|---|---|
| Mecanismo | Lanza Claudes **interactivos** en terminales **kitty** | Lanza un sub-agente via el **Agent tool** (no interactivo) |
| Visibilidad | El humano **ve y habla** con cada secundario en su kitty | El sub-agente corre headless; el humano no lo ve |
| Persistencia | El secundario **vive en su terminal**, se puede retomar (`claude --resume`) | El sub-agente termina y devuelve su texto final |
| Aislamiento | worktree / sub-repo / scope de archivos, impuesto en el prompt | worktree `auto/<issue>` gestionado por el propio `fn-orquestador` |
| Gobierno | El humano coordina via el orquestador; iteración en vivo | Bucle autónomo CONSTRUIR→EJECUTAR→...→MEJORAR hasta converger, PR draft |
| Regla de referencia | esta página | `.claude/rules/autonomous_loop.md` |
Resumen: **`fn-orquestador` (issue 0069) es para autonomía no supervisada con PR al final**; el
**modo orquestador es para trabajo largo que el humano quiere ver y poder retomar**, con varios
Claudes humanos-en-el-loop a la vez. Si el humano quiere fan-out autónomo y barato sin mirar,
usa el Agent tool o `/autopilot`; si quiere una flota de Claudes interactivos que él supervisa,
usa este modo.
## El ciclo del orquestador (8 pasos)
### 1. Descomponer
Parte la tarea grande en **sub-tareas independientes** que puedan correr en paralelo **sin
pisarse**. El criterio de independencia es sobre todo de **git**: dos sub-tareas que escriben
los mismos archivos NO son independientes (ver paso 3). Buenas líneas de corte: una app/sub-repo
distinto por secundario; un dominio de funciones distinto; un módulo o paquete disjunto; el
frontend vs el backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas
en un secundario, o las serializas (una después de otra), o las das scopes de archivos disjuntos.
### 2. Lanzar cada secundario
Comando canónico de lanzamiento (memoria `lanzar-agentes-skip-permissions`), **siempre** con
`--dangerously-skip-permissions` porque los secundarios trabajan autónomos y desatendidos y los
prompts de permiso en cada Bash los atascarían:
```bash
setsid nohup kitty --title "<PROYECTO> · <subtarea>" --directory <dir-aislado> \
zsh -ic 'claude --dangerously-skip-permissions "$(cat /tmp/orq_<slug>.md)"; exec zsh' \
>/tmp/orq_<slug>_kitty.log 2>&1 & disown
```
`setsid nohup ... & disown` hace que la kitty sobreviva al cierre de la terminal padre. El
`zsh -ic '...; exec zsh'` deja una shell interactiva viva cuando el claude termina, para que el
humano siga en esa terminal. El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque.
**Prefiere la función del registry** en vez de teclear el one-liner a mano (registry-first,
queda en telemetría):
```bash
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
```
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` — lanza el secundario con
el comando canónico exacto y devuelve el log donde se ve el arranque. Valida que el dir y el
prompt_file existan y que kitty esté instalado.
### 3. Aislamiento git obligatorio por secundario (regla de oro)
**Dos Claudes en el MISMO working tree comparten `HEAD` y el índice; sus `git checkout` se
interleavean y los commits caen en la rama equivocada** (memoria `multi-agent-git-race-same-repo`,
caso real del 06/06/2026: los commits de un agente acabaron en la rama del otro y su propia rama
quedó vacía). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador elige
cuál y se lo **impone** en el prompt del secundario:
| Opción | Cómo | Cuándo |
|---|---|---|
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno tiene su `.git` independiente (regla `apps_subrepo.md`) | Cuando las sub-tareas caen en apps/analyses/projects distintos. Es el aislamiento natural del monorepo. |
| **(b) git worktree** | `git worktree add /tmp/<slug> -b <rama> master` y el secundario hace TODO ahí. Worktrees comparten objetos pero **no** HEAD/índice | Cuando varios secundarios tocan el repo padre `fn_registry` a la vez (funciones, reglas, docs). |
| **(c) Scope de archivos disjunto** | Mismo working tree pero cada secundario commitea **solo sus paths**: `git add <paths-específicos>`, **nunca** `git add -A` | Último recurso, solo si los scopes están garantizados disjuntos y no hay `git checkout` de rama de por medio. Frágil; prefiere (a) o (b). |
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree
principal, y pásale al secundario el path del worktree como `<dir-aislado>`.
### 4. El prompt de cada secundario
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**;
el prompt debe ser **autocontenido**. Incluye SIEMPRE:
1. **Objetivo claro** — qué construir/arreglar, acotado y verificable.
2. **Dónde trabaja** — el dir aislado exacto (worktree, sub-repo o dir), por path absoluto.
3. **Reglas de aislamiento git** — qué NO tocar (otros repos/worktrees, el working tree
principal `~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add`
de paths específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin
merge a master (lo integra el orquestador).
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con
evidencia ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y
`.claude/rules/dod_quality.md`. Reports son artefacto local gitignored: se escriben, no se
commitean.
5. **Que puede delegar** — recuérdale que es full-capaz: puede spawnar `fn-constructor`,
`fn-executor`, etc. via el Agent tool, y debe seguir registry-first (`registry_calls.md`,
`delegation.md`).
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
kitty vea el estado sin abrir el report.
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
### 5. Seguir la flota
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La fuente de verdad del
mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json` (memoria
`claude-session-pid-mapping`). Usa la función del registry para listarla:
```bash
./fn run list_claude_agents # tabla: PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD
./fn run list_claude_agents --json # para parsear y decidir
```
- `list_claude_agents_bash_infra([--json] [--exclude-current])` — cruza `pgrep -x claude` con los
`sessions/<PID>.json` (con validación anti-PID-reciclado), marca tu propia sesión como `SELF`,
y reporta cwd + sessionId de cada secundario (para retomar con `claude --resume <sessionId>`).
Tu tabla de seguimiento, una fila por secundario:
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|---|---|---|---|---|---|---|---|
| docs | fn_registry · docs | 3637133 | /tmp/orq_docs_wt | orq/docs | /tmp/orq_docs_kitty.log | reports/00NN-…-docs.md | en curso |
Cuando un secundario parezca terminado, confirma: ¿pusheó la rama? ¿escribió el report? Lee el
report (`reports/`), revisa los commits de su rama (`git -C <dir> log --oneline`).
### 6. NUNCA `pkill`/`killall` sobre claude
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
Para parar un secundario:
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`):
`kill <PID>` (o `kill <KITTY_PID>` para cerrar su ventana). Verifica que NO es tu `SELF`.
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; tiene
`--exclude-current` para no tocarte a ti. Es dry-run por defecto; `--go` para ejecutar.
### 7. Integrar
Cuando un secundario termina (rama pusheada + report verde):
1. **Revisa** su diff y su report. Si el report no trae evidencia ejecutable o falla la DoD,
devuélvele trabajo (el humano puede saltar a su kitty, o tú le mandas otro prompt).
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`
checked-out): `git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo que
corresponda al sub-repo. Para funciones nuevas del registry padre, sus archivos viajan en la
rama y el merge los lleva a master.
3. **Informa al humano** y **resume el estado de la flota** en cada turno: quién terminó, quién
sigue, qué se integró, qué falta.
### 8. kitty vs Agent tool — cuándo cada uno
- **kitty (este modo)**: trabajo **largo e interactivo** que el humano quiere **ver** y poder
**retomar** — implementar una feature de horas, depurar en vivo, una sesión que evoluciona.
- **Agent tool directo**: fan-out **acotado y no interactivo** — buscar en el codebase, crear
una función con `fn-constructor`, auditar N apps con `fn-recopilador`. Más barato, sin
terminal, sin supervisión humana. Para esto NO lances kitty: usa `Agent(...)` y ya.
Regla práctica: si el humano va a querer hablar con ello o mirarlo trabajar → kitty. Si es una
sub-tarea que devuelve un resultado y se acabó → Agent tool.
## Reglas duras del modo
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
encuentras escribiendo tú la feature, párate: ¿no debería ser un secundario?
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree
sin worktrees/sub-repos/scopes disjuntos. Es la causa nº1 de commits perdidos.
- **El prompt del secundario lleva SIEMPRE las reglas de aislamiento.** Un prompt sin "trabaja
aquí, no toques aquello, commitea así" es un secundario que contaminará otro repo.
- **Nunca `git add -A` en un secundario** salvo que su dir aislado sea exclusivamente suyo
(worktree/sub-repo). En scope compartido, paths específicos.
- **Nunca `pkill`/`killall claude`.** Kill por PID exacto o `reboot_all_claudes --exclude-current`.
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
## Anti-patrones
| Anti-patrón | Por qué es malo | En su lugar |
|---|---|---|
| `pkill claude` para parar la flota | Te mata a ti (el orquestador) también | Kill por PID exacto / `reboot_all_claudes --exclude-current` |
| Dos secundarios en el mismo working tree | Comparten HEAD/índice → commits dispersos, ramas vacías | worktree / sub-repo / scope disjunto por secundario |
| Prompt de secundario sin reglas de aislamiento | El secundario contamina el repo padre u otro worktree | El prompt fija dir, qué NO tocar, rama y cómo commitear |
| `git add -A` en scope compartido | Arrastra cambios de otra sub-tarea al commit | `git add <paths-específicos>` |
| Lanzar kitty para un fan-out trivial | Caro y sin supervisión que aporte | Agent tool directo (`fn-constructor`, `Explore`, …) |
| Hacer tú la feature "porque es rápido" | Pierdes el sentido del modo; el humano no lo ve evolucionar | Descompón y lanza un secundario |
| Lanzar sin `--dangerously-skip-permissions` | El secundario se atasca pidiendo permiso en cada Bash | Siempre `--dangerously-skip-permissions` (riesgo asumido) |
| Mergear desde el dir del secundario | Master suele estar en el working tree principal; colisión de HEAD | Mergear desde `~/fn_registry` |
## Funciones del registry que usa este modo (grupo `orchestration`)
| Función | Para qué |
|---|---|
| `launch_claude_agent_kitty_bash_infra` | Lanzar un secundario en kitty con prompt autónomo + `--dangerously-skip-permissions` |
| `list_claude_agents_bash_infra` | Listar la flota de Claudes vivos (PID, sessionId, cwd, status, kitty) para seguirla |
| `reboot_all_claudes_bash_infra` | Reiniciar/parar la flota retomando sesiones; `--exclude-current` para no tocarte |
## Ejemplo end-to-end
Tarea grande: *"añade un endpoint `/api/health` al backend de la app `kanban` y, en paralelo,
documenta el grupo de capacidad `deploy` en `docs/capabilities/deploy.md`"*. Dos piezas
independientes: una toca el sub-repo `apps/kanban` (su propio `.git`), la otra toca el repo
padre `fn_registry` (docs). Aislamiento natural distinto para cada una.
```bash
# 1. Descomponer → 2 secundarios independientes:
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
# B) doc capability → worktree del padre (aislamiento (b))
# 2. Preparar aislamiento de B (worktree del padre; A ya está aislado por su sub-repo):
git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
# 3. Escribir los prompts autónomos (autocontenidos, con reglas de aislamiento):
# /tmp/orq_health.md → "trabaja en apps/kanban (sub-repo propio), rama issue/health,
# commits atómicos de tus paths, push al terminar, report en reports/. No toques el
# repo padre. Reporta tu progreso en esta terminal."
# /tmp/orq_capdoc.md → "trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy,
# toca solo docs/capabilities/deploy.md, git add de ese path, push al terminar, report
# en reports/. No toques ~/fn_registry. Reporta tu progreso en esta terminal."
# 4. Lanzar ambos secundarios (cada uno su kitty, su dir aislado):
./fn run launch_claude_agent_kitty "kanban · health endpoint" \
~/fn_registry/apps/kanban /tmp/orq_health.md
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" \
/tmp/orq_capdoc /tmp/orq_capdoc.md
# 5. Seguir la flota (cada turno):
./fn run list_claude_agents
# → tabla con los 2 secundarios vivos (PID, cwd, sessionId, status) + tu SELF.
# Lee /tmp/orq_*_kitty.log para el arranque; cuando terminen, lee sus reports/.
# 7. Integrar (desde el working tree principal):
git -C ~/fn_registry/apps/kanban merge --no-ff issue/health # sub-repo de la app
git -C ~/fn_registry merge --no-ff orq/cap-deploy # repo padre (la doc)
git -C ~/fn_registry worktree remove /tmp/orq_capdoc # limpiar worktree
# Resumen al humano: A integrado (endpoint + test verde), B integrado (doc),
# flota vacía. Tarea grande hecha.
```
## Salida del modo
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la
flota: secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su kitty
para que el humano decida), y los reports generados. Si quedan secundarios vivos, recuérdale que
`list_claude_agents` los lista y que para pararlos es kill por PID exacto, nunca `pkill`.
## Relación con otras reglas
- `.claude/rules/autonomous_loop.md``fn-orquestador` (Agent tool, sandbox no-interactivo). Es
lo que este modo **no** es; tenlas claras separadas.
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*`
gitignored): el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un
worktree con una app nueva dentro.
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario:
report con evidencia ejecutable + gaps.
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
registry-first y delegan a `fn-constructor` igual que tú.
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
+4 -1
View File
@@ -21,7 +21,7 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 15 | [projects.md](projects.md) | Projects: agrupar apps, analysis y vaults bajo un tema |
| 16 | [kiss.md](kiss.md) | KISS en proyectos y apps: cuestionar herramientas externas, sin abstracciones especulativas |
| 17 | [apps_tbd.md](apps_tbd.md) | Trunk-based development obligatorio en apps generadas con `fn` (registry exento) |
| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). `git init` dentro de cada app nueva ANTES de limpiar worktree, sino se pierde el codigo |
| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). El padre NUNCA trackea contenido de artefactos hijos (solo `.gitkeep`); nada de `git add -f` sobre apps/analysis/projects o deja el padre dirty. `git init` dentro de cada app nueva ANTES de limpiar worktree, sino se pierde el codigo |
| 18 | [uses_functions.md](uses_functions.md) | Convencion de uses_functions para C++: el .md del consumidor declara las dependencias |
| 19 | [cpp_apps.md](cpp_apps.md) | Estandarizacion de apps C++: estructura, CMake, app.md, sub-repo, runtime — apunta a cpp/PATTERNS.md y cpp/DESIGN_SYSTEM.md como autoritativas |
| 20 | [artefactos.md](artefactos.md) | Termino paraguas para apps, analysis, vaults, projects y playgrounds (todo lo que no es codigo reutilizable) |
@@ -39,3 +39,6 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
| 35 | [llm_invocation.md](llm_invocation.md) | Invocacion de LLM: SIEMPRE `ask_llm` (grupo `claude-direct`, API directa, arranque 0), NUNCA `claude -p` (lento, cold start). One-shot/streaming/tool-loop + legacy `claude_stream_go_core` deprecado. |
| 36 | [reports.md](reports.md) | Reports: reportes de trabajo como artefacto local (entregable de tarea con evidencia). Gitignored salvo `.gitkeep`, NO suben a Gitea ni se indexan (como vaults+playgrounds). Viven en `reports/` o `projects/<p>/reports/`. Convencion + plantilla. ADR 0006. |
| 37 | [flow_replay.md](flow_replay.md) | Flow replay: guardar un flujo web (login, reiniciar server, formulario) como funcion del registry. Patron grabar→destilar→reproducir con jerarquia HTTP puro > headless chromium > visible humanizado. Empieza por Nivel 1. Seguridad: HAR sensible, secrets a pass, acciones con efecto exigen confirmacion. Grupo `flow-replay`. Issue 0087. |
+30
View File
@@ -45,6 +45,36 @@ Cuando el humano corre `/full-git-push` despues del merge, el script `ensure_rep
Todo lo demas (codigo de la app + app.md + appicon + service unit + tests propios de la app) vive en `apps/<name>/.git` independiente.
### REGLA DURA: el repo padre NUNCA trackea contenido de artefactos hijos
El repo padre `fn_registry` solo versiona codigo del registry (`functions/`, `types/`, `registry/`, `cmd/`, `docs/`, `.claude/`, `dev/`, `migrations/`, y el framework/functions/vendor de `cpp/`). NUNCA debe trackear el contenido de un artefacto hijo:
- apps: `apps/*`, `cpp/apps/*`, `projects/*/apps/*`
- analyses: `analysis/*`, `projects/*/analysis/*`
- projects: `projects/*`
Cada artefacto es un sub-repo Gitea independiente con su propio `.git`; su contenido completo (codigo, `app.md`, `analysis.md`, `appicon.*`, binarios, frontend, `local_files/`, tests propios) vive SOLO en ese sub-repo. `fn index` lee los `.md` de registro directamente del disco — no necesitan estar en el git del padre. Lo unico que el padre versiona dentro de esos arboles son los marcadores `.gitkeep` (mantienen `apps/` y `analysis/` presentes cuando estan vacios) y, en `projects/`, los `project.md` template si los hubiera.
**Como se rompe (sintoma = repo padre permanentemente dirty):** un `git add -f apps/<x>/...` (forzado, saltandose el `.gitignore`) o un commit que mete contenido del hijo al padre. Como el archivo ya queda en el indice, el `.gitignore` NO lo vuelve a ignorar y aparece para siempre en `git status` del padre como modificado cada vez que el sub-repo cambia (doble-tracking). Caso real (2026-06-03): `apps/dag_engine/` (31 archivos: Go + frontend + app.md) y `apps/shaders_lab/` (app.md + un binario `.exe` de 23 MB) quedaron forzados al indice del padre y lo dejaban dirty en cada cambio del sub-repo.
**Auditoria (cero salida = sano):**
```bash
git ls-files 'apps/*' 'analysis/*' 'projects/*/apps/*' 'projects/*/analysis/*' 'cpp/apps/*' \
| grep -vE '(^|/)\.gitkeep$'
```
**Fix si aparece contenido trackeado:**
```bash
# --cached SIEMPRE: saca del indice del padre sin borrar el working tree.
# El codigo sigue a salvo en el .git del sub-repo.
git rm -r --cached apps/<x>
git commit -m "chore: untrack contenido del artefacto <x> (es sub-repo Gitea)"
```
NUNCA `git rm` sin `--cached` (borraria el working tree del sub-repo). **Prevencion:** jamas usar `git add -f` sobre paths de artefactos; las reglas `apps/*/`, `analysis/*/`, `projects/*/` del `.gitignore` ya cubren el caso por defecto y solo un force las salta.
### Sintomas de la perdida
Si limpias el worktree y luego corres `ls apps/<name>/`, devuelve "No such file or directory" pese a que el issue aparece cerrado en `dev/issues/completed/`. **Patron** = scaffold sin sub-repo init = trabajo perdido.
+4 -1
View File
@@ -1,6 +1,6 @@
## Artefactos: termino colectivo
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds" cada vez.
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds, reports" cada vez.
Tipos de artefacto:
@@ -11,6 +11,7 @@ Tipos de artefacto:
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
| **playground** | `<artefacto_padre>/playground/` | NO se indexa | no (vive dentro del padre) |
| **report** | `reports/`, `projects/<p>/reports/` | NO se indexa | no (local, gitignored, no sube a Gitea — como vaults) |
Caracteristicas comunes de los artefactos:
- NO son codigo reutilizable. La reutilizacion vive en `functions/`.
@@ -18,6 +19,8 @@ Caracteristicas comunes de los artefactos:
- `pc_locations` los unifica via `entity_type` (app, analysis, project, vault).
- Pueden importar funciones del registry; el registry NUNCA importa de un artefacto.
**Reports** son el caso mas ligero: artefacto local (gitignored salvo `reports/.gitkeep`), NO sube a Gitea ni se versiona en el padre (como los vaults), NO se indexa (como los playgrounds). Convencion en [[reports]]. Pueden vivir sueltos en `reports/` o dentro de un proyecto en `projects/<p>/reports/`.
### Cuando usar el termino
Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos:
+1 -1
View File
@@ -131,7 +131,7 @@ El `if(EXISTS ...)` hace el registro tolerante a apps no clonadas (cada app es s
### 6. Sub-repo Gitea (TBD obligatorio)
Cada app C++ es su propio repo en `dataforge/<name>` con branch `master`. Esto significa:
- El directorio `<app_dir>/` esta en el `.gitignore` de `fn_registry` (excepto `app.md`).
- TODO el directorio `<app_dir>/` (incluido `app.md`, `appicon.*`, binarios y `local_files/`) esta en el `.gitignore` de `fn_registry`: el repo padre NUNCA versiona contenido del artefacto. `fn index` lee `app.md` directo del disco, no del git. NO forzar con `git add -f` — deja el padre dirty. Ver la regla dura en `apps_subrepo.md`.
- El propio directorio tiene `.git/` apuntando al sub-repo.
- TBD obligatorio mientras se desarrolla la app: ver `apps_tbd.md`. Trabajar en `issue/<NNNN>-<slug>` o `quick/<slug>`, mergear a `master` con `--no-ff`.
- Sync entre PCs y push/pull se gestionan con `/full-git-push` y `/full-git-pull`.
+76
View File
@@ -0,0 +1,76 @@
## Flow replay: guardar un flujo web como función reproducible
Cuando una acción web se hace **más de una vez** (login en un panel, reiniciar un servidor
desde su consola, rellenar un formulario recurrente, descargar un export), deja de hacerse a
mano: se **graba una vez y se promueve a función del registry**. Es la doctrina del issue 0087
aplicada a la navegación — el registry crece convirtiendo secuencias repetidas en operaciones
de un solo paso, no inflando funciones existentes.
Grupo de capacidad: `flow-replay`. Página madre: `docs/capabilities/flow-replay.md`. Graba con
el grupo `web-proxy`; destila y reproduce con `flow-replay`.
### El patrón: grabar → destilar → reproducir
1. **Grabar** (una vez, con browser + proxy): `web_proxy` ON, haces la acción a mano,
exportas el tramo a HAR (`query_mitm_flows --har`).
2. **Destilar**: `har_filter_flows_py_cybersecurity` (quita ruido) →
`har_extract_calls_py_cybersecurity` (call specs reproducibles).
3. **Reproducir**, en esta jerarquía de preferencia (de barato a caro):
| Nivel | Mecanismo | Cuándo |
|---|---|---|
| **1 — HTTP puro** | `http_replay_sequence_py_infra` | **Por defecto.** Rápido, headless, scriptable. La mayoría de paneles admin funcionan con cookie de sesión + requests. |
| **2 — headless chromium** | action recipe (reutiliza `cdp_extract_recipe` + `cdp_save_storage_state`) | Token dinámico firmado en cliente, challenge JS obligatorio, WAF con fingerprint. |
| **3 — chromium visible + humanizado** | `cdp_click_xy_human`, `cdp_move_mouse_human` | Headless detectado/bloqueado. Último recurso. |
**Empieza SIEMPRE por el Nivel 1.** Solo baja de nivel cuando el anterior demuestre no
reproducir el efecto. Construir el runner de Nivel 2/3 por adelantado, sin un caso que lo
exija, es especular (KISS): se monta cuando un flujo real falle en HTTP puro.
### Flujo de autoría (cómo guardar una función-acción nueva)
1. Grabar el flujo y exportar el HAR del tramo.
2. `har_filter_flows` + `har_extract_calls` → boceto de la secuencia. El agente **lee** el
HAR (es texto) e identifica los 2-4 requests que producen el efecto (auth + acción +
confirmación), descartando el resto.
3. Parametrizar: marcar los valores variables (ids, tokens) como `{{param}}`; definir las
reglas `extract` para los tokens que una respuesta genera y otro request consume.
4. Validar el replay con `http_replay_sequence`. Si reproduce el efecto sin navegador → Nivel 1.
5. **Promover a función del registry**: delegar a `fn-constructor` una función-acción nombrada
con verbo (`reboot_vps_server_<panel>`, `login_<panel>`, `export_<panel>_report`) que
internamente llama a `http_replay_sequence` con su secuencia fija, recibe los parámetros
del caller y resuelve los secretos desde `pass`/vault. Tag de grupo `flow-replay` + el
dominio que toque (infra, cybersecurity, …). `fn index` + usar en el mismo turno.
### Reglas duras de seguridad
- **El HAR es un secreto**: lleva cookies/tokens en crudo. Gitignored, no subir a Gitea, no
indexar, borrar tras destilar. El output de `har_extract_calls` también, hasta sustituir por
`{{param}}`.
- **Secretos a `pass`/vault**, jamás hardcodeados en la función-acción.
- **Replay con efectos = peligroso.** Una acción destructiva o irreversible (reiniciar, borrar,
pagar, enviar) NUNCA se reproduce a ciegas: la función-acción exige confirmación o un flag
explícito (`confirm=True` / `--yes`) antes de disparar.
- `http_replay_sequence` usa `verify_tls=True` y sigue redirects por defecto; la extracción
JSON es dot-path simple, no JSONPath completo.
### Anti-patrones
| Anti-patrón | Por qué es malo | Sustituir por |
|---|---|---|
| Repetir el flujo a mano cada vez | No capitaliza; lento; propenso a error | Grabar una vez → función-acción |
| Reescribir requests inline en un heredoc/app cada vez | Reinvento, sin telemetría | Función-acción que llama `http_replay_sequence` |
| Empezar por chromium headless "por si acaso" | Más caro y frágil que HTTP puro | Nivel 1 primero, bajar solo si falla |
| Hardcodear cookie/token del HAR en el código | Secreto filtrado + caduca | `{{param}}` desde `pass`/vault |
| Commitear el HAR o el output crudo de extract | Filtración de credenciales | Tratar como secreto, gitignored |
| Replay ciego de un POST destructivo | Daño irreversible | Confirmación / flag explícito |
### Relación con otras reglas
- [[registry_first]] — buscar/reutilizar antes de escribir; la función-acción se delega a
`fn-constructor`, no se escribe inline.
- [[function_growth_and_self_docs]] — el registry crece por promoción de composiciones
repetidas a funciones one-shot (issue 0087); esto es ese patrón para la navegación.
- [[registry_calls]] — invocar las funciones del grupo por los patrones canónicos (MCP /
`fn run` / heredoc que importa).
- Grupo `web-proxy` (`docs/capabilities/web-proxy.md`) — la captura que alimenta la Fase 0.
+1 -1
View File
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, start, stop, kill, restart, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
### Excepciones
+50
View File
@@ -0,0 +1,50 @@
## Invocación de LLM: SIEMPRE `ask_llm`, NUNCA `claude -p`
**REGLA DURA.** Para ejecutar un modelo LLM desde cualquier código del ecosistema (scripts, heredocs, apps, pipelines, agentes), usa el grupo `claude-direct` — empezando por `ask_llm_py_core`. **NUNCA** uses `claude -p` ni lances el binario `claude` como subproceso para obtener una respuesta del modelo.
### Por qué
| | `claude -p` | `ask_llm` / `claude-direct` |
|---|---|---|
| Mecanismo | Lanza Claude Code entero (proceso `claude`) | Habla directo a `api.anthropic.com/v1/messages` |
| Arranque | ~7-15s (carga MCP + `CLAUDE.md` ~100k tokens) | **0 — request HTTP directa** |
| Latencia/msg | ~9-15s | **~2.5s** |
| Coste | Alto (re-carga contexto cada vez) | Mínimo (solo tu prompt) |
| Tools | Las de Claude Code (no controlables) | **Las que tú defines** (`run_claude_tool_loop`) |
| Streaming | indirecto | nativo (`stream_anthropic_messages`) |
`claude -p` es lento, caro y arranca todo Claude Code para una completion. `ask_llm` es la API directa: arranque 0, rápido, con tus propias tools. Usa el token OAuth que Claude Code ya guarda en `~/.claude/.credentials.json`.
### Cómo (según el caso)
| Caso | Usa |
|---|---|
| Pregunta/chat one-shot | `fn run ask_llm "..."` o `from core.ask_llm import ask_llm` |
| Streaming de eventos crudos (text/tool_use deltas) | `stream_anthropic_messages_py_core` |
| Agente con TUS tools (tool-use loop) | `run_claude_tool_loop_py_core` (defines `tools` + `dispatch`) |
| Token OAuth | `load_claude_oauth_token_py_core` (automático dentro de las anteriores) |
| Distribuir fuera del registry | `apps/llm_cli/llm.py` (versión standalone autocontenida) |
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from core.ask_llm import ask_llm
respuesta = ask_llm("resume esto en 3 lineas: ...", model="claude-haiku-4-5-20251001", echo=False)
```
### Legacy
`claude_stream_go_core` (lanza `claude -p --output-format stream-json`) es el **camino antiguo**. No usarlo en código nuevo — preferir las funciones `claude-direct`. Queda solo para compatibilidad de consumidores existentes.
### Excepción acotada
Si una tarea necesita **genuinamente las capacidades de Claude Code** (sus tools nativas, los MCP del repo, plan mode, el contexto del proyecto) y no basta con el modelo + tus propias tools via `run_claude_tool_loop`, entonces NO es una "invocación LLM" simple: documenta por qué en el código. El **default sin excepción es `ask_llm`**.
### Telemetría / auditoría
Un `claude -p` o un `subprocess(["claude", "-p", ...])` en código nuevo es un antipatrón auditable: sustituir por `ask_llm` / `claude-direct`. Buscar usos: `grep -rn 'claude -p' --include='*.py' --include='*.sh' --include='*.go'`.
### Relación con otras reglas
- [[registry_calls]] — patrones canónicos de invocación de funciones; esta regla fija el patrón para la sub-tarea "invocar un LLM".
- [[registry_first]] — reusar antes que reescribir; `ask_llm` es la función reutilizable para LLM.
+17
View File
@@ -28,6 +28,23 @@ projects/{nombre}/
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
- Los vaults reales viven fuera del repo (ej: `~/vaults/{nombre}/`) con symlinks en el proyecto
- `fn index` escanea `projects/*/` y setea `project_id` automaticamente en apps, analyses y vaults
### Cada project es su propio repo Gitea (sub-repo)
Desde 2026-06-05 cada `projects/<nombre>/` es un **repo Gitea independiente** `dataforge/<nombre>` (branch `master`), igual que las apps y los analyses. El repo del project versiona **solo las docs de nivel-project** (`project.md`, `CONVENTIONS.md` y demás `.md`/`.claude/` propios del project). El contenido de los hijos NO se versiona aquí: cada `apps/<app>/` y cada `analysis/<a>/` es su propio sub-repo Gitea y queda excluido por el `.gitignore` del project:
```gitignore
apps/*/
analysis/*/
vaults/*
!vaults/.gitkeep
```
- **Crear el repo del project**: `ensure_repo_synced_bash_infra projects/<nombre> dataforge <nombre> master "init: project <nombre>"` (necesita `GITEA_URL` + `GITEA_TOKEN`; el token está en `pass gitea/dataforge-git-token`). Crear el `.gitignore` de arriba ANTES, para no trackear el contenido de los sub-repos hijos.
- **Push/pull**: `/full-git-push` y `/full-git-pull` ya lo manejan automáticamente — `discover_git_repos_bash_infra` descubre cualquier `.git` bajo `fn_registry`, incluidos los projects.
- **`repo_url`** en `project.md` apunta al repo del project; los `repo_url` de cada app viven en su `app.md`. Así el project "referencia" sus sub-repos sin git submodules (KISS).
- El repo padre `fn_registry` sigue ignorando `projects/*/` entero (regla `apps_subrepo.md`): nunca trackea contenido de projects.
- Estado actual: `dataforge/web_scraping`, `dataforge/fn_monitoring`, `dataforge/message_bus`.
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
### Raiz vs proyecto
+78
View File
@@ -0,0 +1,78 @@
## Reports: reportes de trabajo como artefacto local
Un **report** es el entregable escrito de una tarea no trivial: qué se hizo, cómo se verificó y qué quedó pendiente, en formato copiable de un vistazo. Sirve para conservar el resultado fuera del chat y compartirlo rápido pasando la ruta del archivo.
Un report es un **artefacto** (ver `artefactos.md`), no documentación del registry. En consecuencia:
- **NO se versiona en el git del padre `fn_registry`** ni en ningún sub-repo: `reports/*` está en el `.gitignore` (solo el marcador `reports/.gitkeep` se versiona). Igual que los **vaults**.
- **NO sube a Gitea**: un report no tiene repo propio. Vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`.
- **NO se indexa en `registry.db`**: no hay tabla `reports` ni schema. KISS — son texto plano efímero, como los `playgrounds`.
### Qué NO es un report
| Es | Va a |
|---|---|
| Decisión de diseño (qué se decidió y por qué) | `docs/adr/` (versionado) |
| Norma operativa / convención | `.claude/rules/` (versionado) |
| Bitácora cronológica libre | `docs/diary/` (versionado) |
| **Resultado de una tarea concreta + su evidencia** | **`reports/` (artefacto local, NO versionado)** |
Si durante el trabajo aparece una decisión de diseño, esa decisión va a `docs/adr/` y el report solo la referencia.
### Ubicación
Como cualquier artefacto, un report puede vivir en dos sitios:
| Ubicación | Para qué |
|---|---|
| `reports/` (raíz) | Reportes que no pertenecen a ningún proyecto |
| `projects/<p>/reports/` | Reportes del trabajo de un proyecto concreto |
Ambas rutas están gitignored (`reports/*`, `projects/*/reports/`). Se pueden crear subcarpetas bajo `reports/` para agrupar (`reports/browser/`, `reports/audits/`, …).
### Convención de nombre
```
NNNN-YYYY-MM-DD-slug-corto.md
```
- `NNNN` — número incremental de 4 dígitos por carpeta (0001, 0002, …). Referencia corta ("report 0003").
- `YYYY-MM-DD` — fecha del trabajo (ISO en el nombre; en el cuerpo, fechas en formato europeo DD/MM/AAAA).
- `slug-corto` — kebab-case descriptivo. Ej: `browser-domain-audit-fixes`.
### Plantilla mínima
```markdown
# Report NNNN — Título
- **Fecha:** DD/MM/AAAA
- **Autor:** (agente/humano)
- **Ámbito:** (dominio/app/módulo tocado)
- **Estado:** done | parcial | bloqueado
## Resumen
Qué se hizo y el resultado, en 2-4 líneas.
## Cambios
Tabla o lista de lo tocado/creado, con el porqué.
## Verificación
Comandos ejecutados + salida cruda (build/test/vet/e2e). Sin "verde" sin evidencia.
## Gaps / pendientes
Lo que NO se cubrió y por qué (honesto: requiere Chrome, scope, etc.).
```
### Reglas
- **Cuándo escribir uno**: auditorías, tandas de fixes con verificación, refactors, investigaciones — cualquier trabajo cuyo resumen pedirías "para compartir rápido". Un fix de una línea NO necesita report; basta el commit.
- **Evidencia ejecutable obligatoria**: cada "pasa" lleva su comando/salida. Nada de smoke "no petó". Alineado con `dod_quality.md`.
- **Honestidad sobre gaps**: declarar siempre qué quedó sin cubrir.
- **Índice opcional**: si una carpeta de reports acumula muchos, mantener un `INDEX.md` local (también gitignored) ayuda a navegar; no es obligatorio.
### Relación con otras reglas y ADRs
- [[artefactos]] — report es un tipo de artefacto (no código reutilizable, ciclo de vida propio).
- [[playgrounds]] — mismo espíritu (artefacto local no indexado); el playground es prototipo de código, el report es resultado escrito.
- [[dod_quality]] — los reports heredan su exigencia de evidencia + gaps.
- ADR 0006 (`docs/adr/0006-reports-folder.md`) — decisión que crea la carpeta `reports/`.
+35 -6
View File
@@ -1,11 +1,28 @@
{
"permissions": {
"allow": [
"Bash(CGO_ENABLED=1 go test *)",
"Bash(sqlite3 *)",
"Read(//home/enmanuel/.claude/**)"
]
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
],
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh" },
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh"
}
]
}
],
@@ -13,21 +30,33 @@
{
"matcher": "Bash|Edit|Write|MultiEdit|mcp__registry__.*",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh"
}
]
},
{
"matcher": "Edit|Write|MultiEdit|mcp__registry__fn_create_function",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh" },
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
}
]
}
]
+9 -3
View File
@@ -46,6 +46,13 @@ projects/*/
vaults/*/
!vaults/vault.yaml
# Reports — artefacto local: reportes de trabajo. Como los vaults, NO suben a
# Gitea ni se versionan en el padre (solo el marcador .gitkeep). Conviven en
# reports/ (raíz) o projects/<p>/reports/. Convención: .claude/rules/reports.md
reports/*
!reports/.gitkeep
projects/*/reports/
# Node / pnpm
**/node_modules/
@@ -67,8 +74,8 @@ worktrees/
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
temp/
# C++ build artifacts
cpp/build/
# C++ build artifacts (build/, build-tests/, build-windows/, etc.)
cpp/build*/
/build/
# OS
@@ -81,7 +88,6 @@ Thumbs.db
broken_paths.txt
imgui.ini
prompts/
kotlin/functions/ui/
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
**/version_generated.h
+7
View File
@@ -0,0 +1,7 @@
{
"0ea5e69b-9607-4f11-b740-005e835faef6": {
"version": "2.4.0",
"created_at": "2026-06-03T17:52:16.077873+00:00",
"document_version": "2.0.0"
}
}
Binary file not shown.
@@ -0,0 +1,71 @@
---
name: apply_chromium_cdp_flag
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]"
description: "Gestiona de forma idempotente el fragmento /etc/chromium.d/cdp que activa Chrome DevTools Protocol global en todo chromium que el usuario lance en el equipo. Escribe, actualiza o borra el fragmento con backup automático."
tags: [navegator, chromium, cdp, devtools, browser, automation, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: "--port N"
desc: "Puerto TCP donde Chromium escuchará conexiones CDP. Default 9222."
- name: "--network"
desc: "Si se pasa, añade --remote-debugging-address=0.0.0.0 (accesible desde la red local). Por defecto solo loopback (127.0.0.1). Imprime advertencia de seguridad."
- name: "--fragment-path <path>"
desc: "Ruta del fragmento a escribir/borrar. Default /etc/chromium.d/cdp."
- name: "--remove"
desc: "Borra el fragmento (desactiva CDP global). Idempotente: si no existe, no-op."
- name: "--dry-run"
desc: "Imprime el fragmento que se escribiría sin tocar nada. No requiere sudo."
output: "Sale 0 en éxito (aplicado, ya-aplicado, o eliminado). Sale != 0 en error con mensaje a stderr. En caso de actualización imprime ruta del backup creado."
file_path: "bash/functions/browser/apply_chromium_cdp_flag.sh"
---
## Ejemplo
```bash
# Activar CDP global en loopback puerto 9222 (proyecto web_scraping, regla 8)
source bash/functions/browser/apply_chromium_cdp_flag.sh
apply_chromium_cdp_flag
# Previsualizar el fragmento sin escribir nada (no requiere sudo)
apply_chromium_cdp_flag --port 9222 --dry-run
# Puerto alternativo (para correr en paralelo al navegador del usuario)
apply_chromium_cdp_flag --port 9333
# Activar expuesto a la red local (RIESGO: cualquier host de la LAN puede controlar el navegador)
apply_chromium_cdp_flag --port 9222 --network
# Desactivar CDP global
apply_chromium_cdp_flag --remove
# Ruta personalizada (útil en pruebas o entornos chroot)
apply_chromium_cdp_flag --port 9222 --fragment-path /tmp/test_cdp_fragment --dry-run
```
## Cuando usarla
Al preparar un PC nuevo para controlar el chromium diario del usuario vía CDP (primer setup del proyecto `web_scraping`, regla 8). Al cambiar el puerto CDP del sistema. Al desactivar esa capacidad antes de prestar o formatear el equipo. Sustituye el paso manual "crear `/etc/chromium.d/cdp` con sudo" documentado en `CHROMIUM_SYSTEM.md`.
## Gotchas
- **Requiere sudo** para escribir bajo `/etc/`. En este equipo usar `pass show claude/sudo | sudo -S apply_chromium_cdp_flag` o ejecutar como root.
- **`--network` (0.0.0.0) es un riesgo de seguridad serio**: cualquier máquina en la red local puede conectarse al puerto CDP y controlar Chromium completamente (leer cookies, sesiones, inyectar JavaScript). Solo usar en entornos de red aislados o laboratorios.
- **El chromium ya abierto antes del cambio no hereda el flag** hasta que se reinicie. El fragmento solo se aplica en el próximo arranque de `/usr/bin/chromium`.
- **Dos procesos chromium no pueden compartir el mismo puerto**. Si el usuario ya tiene un chromium con CDP en 9222, la automatización dedicada debe arrancar con `chrome_launch_go_browser` en otro puerto (ej. 9333) o usar `--port 9333` en esta función.
- **Idempotente**: si el fragmento ya existe con contenido idéntico, la función sale 0 sin tocar nada ni crear backup.
- **Backup automático**: al sobreescribir, crea `<path>.bak.YYYYMMDD`. Si ya existe un backup del mismo día, no lo sobreescribe (el primero del día se preserva).
- **Validación post-escritura**: tras escribir, verifica con `grep` que la línea `export CHROMIUM_FLAGS` con el puerto correcto quedó en el archivo. Si falla, restaura el backup y sale con error.
- Ver `projects/web_scraping/CHROMIUM_SYSTEM.md` para el contexto completo del sistema CDP de este equipo.
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# apply_chromium_cdp_flag — gestiona el fragmento /etc/chromium.d/cdp que activa CDP global.
#
# Uso:
# apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]
apply_chromium_cdp_flag() {
local port=9222
local network=0
local fragment_path="/etc/chromium.d/cdp"
local remove=0
local dry_run=0
# Parseo de argumentos
while [[ $# -gt 0 ]]; do
case "$1" in
--port)
port="$2"
shift 2
;;
--network)
network=1
shift
;;
--fragment-path)
fragment_path="$2"
shift 2
;;
--remove)
remove=1
shift
;;
--dry-run)
dry_run=1
shift
;;
*)
echo "apply_chromium_cdp_flag: argumento desconocido: $1" >&2
return 1
;;
esac
done
# Validación del puerto
if ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "apply_chromium_cdp_flag: puerto inválido: $port (debe ser 1-65535)" >&2
return 1
fi
# Construcción del contenido del fragmento
local flags_line
if (( network )); then
echo "ADVERTENCIA DE SEGURIDAD: --network activa --remote-debugging-address=0.0.0.0." >&2
echo "El navegador quedará expuesto a toda la red local. Cualquier host en la red" >&2
echo "podrá controlar Chromium remotamente y leer todas las sesiones abiertas." >&2
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=* --remote-debugging-address=0.0.0.0"'
else
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=*"'
fi
local mode_label
if (( network )); then
mode_label="network (0.0.0.0)"
else
mode_label="loopback (127.0.0.1)"
fi
local fragment_content
fragment_content="# CDP global para automatizacion del navegador del usuario (proyecto web_scraping, regla 8).
# Bind ${mode_label} por defecto: el puerto solo
# es accesible desde 127.0.0.1, no desde la red.
${flags_line}"
# Modo --dry-run: solo mostrar y salir
if (( dry_run )); then
echo "--- dry-run: fragmento que se escribiría en ${fragment_path} ---"
echo "${fragment_content}"
echo "--- fin dry-run ---"
return 0
fi
# Modo --remove
if (( remove )); then
if [[ ! -e "$fragment_path" ]]; then
echo "apply_chromium_cdp_flag: ${fragment_path} no existe, nada que borrar."
return 0
fi
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
if [[ ! -e "$backup_path" ]]; then
if [[ $EUID -eq 0 ]]; then
cp "$fragment_path" "$backup_path"
else
sudo cp "$fragment_path" "$backup_path" || {
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
return 1
}
fi
fi
if [[ $EUID -eq 0 ]]; then
rm "$fragment_path"
else
sudo rm "$fragment_path" || {
echo "apply_chromium_cdp_flag: no se pudo borrar ${fragment_path}" >&2
return 1
}
fi
echo "apply_chromium_cdp_flag: fragmento eliminado (backup: ${backup_path})"
echo "Nota: el chromium ya abierto antes de este cambio no lo hereda hasta reiniciarlo."
return 0
fi
# Idempotencia: comparar con contenido actual
if [[ -f "$fragment_path" ]]; then
local current_content
current_content=$(cat "$fragment_path" 2>/dev/null)
if [[ "$current_content" == "$fragment_content" ]]; then
echo "apply_chromium_cdp_flag: ya aplicado, sin cambios (${fragment_path})."
return 0
fi
fi
# Crear directorio si falta
local fragment_dir
fragment_dir=$(dirname "$fragment_path")
if [[ ! -d "$fragment_dir" ]]; then
if [[ $EUID -eq 0 ]]; then
mkdir -p "$fragment_dir"
else
sudo mkdir -p "$fragment_dir" || {
echo "apply_chromium_cdp_flag: no se pudo crear ${fragment_dir}" >&2
return 1
}
fi
fi
# Backup si ya existe y difiere
if [[ -e "$fragment_path" ]]; then
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
if [[ ! -e "$backup_path" ]]; then
if [[ $EUID -eq 0 ]]; then
cp "$fragment_path" "$backup_path"
else
sudo cp "$fragment_path" "$backup_path" || {
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
return 1
}
fi
echo "apply_chromium_cdp_flag: backup creado en ${backup_path}"
fi
fi
# Escribir fragmento
local tmpfile
tmpfile=$(mktemp)
printf '%s\n' "$fragment_content" > "$tmpfile"
if [[ $EUID -eq 0 ]]; then
cp "$tmpfile" "$fragment_path"
chmod 644 "$fragment_path"
else
sudo cp "$tmpfile" "$fragment_path" || {
rm -f "$tmpfile"
echo "apply_chromium_cdp_flag: no se pudo escribir ${fragment_path}" >&2
return 1
}
sudo chmod 644 "$fragment_path" 2>/dev/null || true
fi
rm -f "$tmpfile"
# Validación post-escritura
local expected_line="--remote-debugging-port=${port}"
if ! grep -qF "$expected_line" "$fragment_path" 2>/dev/null; then
echo "apply_chromium_cdp_flag: validación fallida — la línea export no apareció en ${fragment_path}." >&2
# Restaurar backup si existe
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
if [[ -f "$backup_path" ]]; then
if [[ $EUID -eq 0 ]]; then
cp "$backup_path" "$fragment_path"
else
sudo cp "$backup_path" "$fragment_path" 2>/dev/null || true
fi
echo "apply_chromium_cdp_flag: backup restaurado desde ${backup_path}" >&2
fi
return 1
fi
# Resumen final
echo "apply_chromium_cdp_flag: CDP global activado correctamente."
echo " Fragmento : ${fragment_path}"
echo " Puerto : ${port}"
echo " Modo : ${mode_label}"
echo ""
echo "Nota: el chromium ya abierto antes de este cambio no hereda el flag hasta reiniciarlo."
echo "Nota: dos procesos chromium no pueden compartir el mismo puerto; usa --port <otro> para"
echo " automatización dedicada que corra en paralelo al navegador del usuario."
}
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
# solo se define la función y no se ejecuta nada.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
apply_chromium_cdp_flag "$@"
fi
@@ -0,0 +1,90 @@
---
name: apply_chromium_extension_policy
kind: function
lang: bash
domain: browser
version: "1.1.0"
purity: impure
signature: "apply_chromium_extension_policy [--keep <ext_id[=update_url]>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]"
description: "Escribe de forma idempotente la política managed de Chromium combinando ExtensionInstallForcelist (force-instala la whitelist --keep) y ExtensionInstallBlocklist (bloquea y desinstala la blocklist --block). No usa el comodín \"*\" blocked, por lo que NO afecta a las extensiones unpacked cargadas con --load-extension. Guarda backup fuera del directorio managed/ (que Chromium lee entero). Requiere sudo para escribir en /etc; en --dry-run no toca el sistema."
tags: [chromium, extensions, policy, browser, navegator, managed-policy, idempotent]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--keep <ext_id[=update_url]>"
desc: "ID de extensión a force-instalar (repetible). Va a ExtensionInstallForcelist. Forma simple '<id>' usa el update_url por defecto (Web Store). Forma '<id>=<update_url>' fuerza una extensión self-hosted: por ejemplo '<id>=file:///home/u/.web_proxy/update.xml' instala un .crx local empaquetado bajo managed policy (necesario porque --load-extension queda desactivado cuando hay managed policy). Ejemplo Web Store: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)."
- name: "--block <ext_id>"
desc: "ID de extensión a bloquear y desinstalar en cualquier perfil (repetible). Va a ExtensionInstallBlocklist. Solo afecta a los IDs listados; el resto de extensiones no se toca."
- name: "--policy-path <path>"
desc: "Ruta del JSON de managed policy. Default: /etc/chromium/policies/managed/extensions.json."
- name: "--update-url <url>"
desc: "URL del servicio de actualización de extensiones. Default: https://clients2.google.com/service/update2/crx."
- name: "--dry-run"
desc: "Imprime el JSON que se escribiría sin tocar el sistema (no requiere sudo)."
output: "Escribe el JSON de política en policy-path y emite a stdout un resumen: extensiones forzadas, bloqueadas, ruta, backup creado y recordatorio de reinicio de Chromium. Sale 0 si la política se aplicó o ya estaba vigente. Sale != 0 en error. Requiere al menos un --keep o --block."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/apply_chromium_extension_policy.sh"
---
## Ejemplo
```bash
# Dejar el perfil con solo uBlock Origin Lite: forzar uBlock + bloquear las 3 que estorban
# al scraping (Dark Reader, NoScript, OneTab). Proyecto web_scraping, regla 9.
source bash/functions/browser/apply_chromium_extension_policy.sh
apply_chromium_extension_policy \
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
--block eimadpbcbfnmbkopoojfekhnkhdbieeh \
--block doojmbjmlfjjnbmnoijecmcbfeoakpjm \
--block chphlpgkkbolifaimnlloiipkdnihall
# Previsualizar sin tocar el sistema (sin sudo)
apply_chromium_extension_policy --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
# Ejecutar como root para el sudo no interactivo de este equipo
pass show claude/sudo | sudo -S bash bash/functions/browser/apply_chromium_extension_policy.sh \
--keep ddkjiahejlhfcafbddmgiahcphecmpfh --block eimadpbcbfnmbkopoojfekhnkhdbieeh
```
La policy por sí sola evita la reinstalación pero NO desinstala lo ya presente en un perfil concreto:
combínala con `clean_chrome_profile_extensions_bash_browser` (con Chromium cerrado) para purgar del
disco las extensiones ya instaladas.
## Cuando usarla
Al preparar un PC nuevo o cambiar qué extensiones de Chrome Web Store deben estar (o no estar) en
cualquier perfil de Chromium del equipo. Reemplaza el paso manual de editar el JSON de policy con
sudo. `--keep` fuerza y fija las imprescindibles; `--block` elimina las molestas sin tocar el resto.
## Gotchas
- **El backup NUNCA va dentro de `managed/`** (lo gestiona la función, pero es la lección clave): Chromium
lee **todos** los archivos del directorio `policies/managed/` sin filtrar por extensión de nombre. Un
`extensions.json.bak.YYYYMMDD` dentro de `managed/` se mergea con la policy efectiva y **reinyecta** las
extensiones del backup (se ven como `location=7` external_policy_download y vuelven aunque las borres).
Por eso la función guarda los backups en `policies/policy-backups/`, fuera de `managed/`. Si encuentras
backups antiguos dentro de `managed/`, muévelos fuera.
- **No usa el comodín `"*": blocked`**: ese modo desinstala todo lo no-whitelist pero también **bloquea las
extensiones unpacked** (`--load-extension`), rompiendo cosas como la extensión de captura de `web_proxy`
con el error "Loading of unpacked extensions is disabled by the administrator". Esta función bloquea solo
los IDs de `--block`.
- **`--load-extension` y managed policy son incompatibles en Chromium 137+**: con CUALQUIER managed policy
presente, Chromium desactiva `--load-extension` ("disabled by the administrator"). Para cargar una
extensión local junto a una managed policy hay que empaquetarla (.crx + update_url) o usar `--proxy-server`
directo en el caso de `web_proxy`.
- **Requiere sudo** para escribir en `/etc/chromium/policies/managed/`. En este equipo: `pass show claude/sudo | sudo -S <cmd>`.
- **Chrome cachea la política en memoria**: cerrar TODOS los Chromium (`pkill -9 chromium`) y relanzar, o `chrome://policy` → "Reload policies".
- **Idempotente**: si el archivo ya tiene el mismo contenido, no-op y sale 0.
- Referencia del sistema completo: `projects/web_scraping/CHROMIUM_SYSTEM.md`.
## Capability growth log
- v1.2.0 (2026-06-05) — `--keep` acepta `<id>=<update_url>` para force-instalar extensiones self-hosted (p.ej. un `.crx` local vía `file://` a un `update.xml`), que es la forma de cargar una extensión propia cuando hay managed policy y `--load-extension` está desactivado.
- v1.1.0 (2026-06-05) — añade `--block` (ExtensionInstallBlocklist); reemplaza el modo `ExtensionSettings "*": blocked` (rompía extensiones unpacked) por blocklist específica; mueve los backups fuera de `managed/` (Chromium lee todo el directorio y un `.bak` ahí reinyectaba extensiones).
- v1.0.0 (2026-06-05) — baseline: ExtensionInstallForcelist con whitelist `--keep`.
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# apply_chromium_extension_policy — Escribe de forma idempotente la política managed de Chromium
# que fuerza la instalación de una whitelist de extensiones y bloquea (desinstala) una blocklist
# concreta, sin tocar el resto. Usa ExtensionInstallForcelist + ExtensionInstallBlocklist.
apply_chromium_extension_policy() {
local policy_path="/etc/chromium/policies/managed/extensions.json"
local update_url="https://clients2.google.com/service/update2/crx"
local dry_run=0
local -a keep_ids=()
local -a block_ids=()
# --- Parseo de argumentos ---
while [[ $# -gt 0 ]]; do
case "$1" in
--keep)
if [[ -z "${2:-}" ]]; then
echo "apply_chromium_extension_policy: --keep requiere un ID de extensión" >&2
return 1
fi
keep_ids+=("$2")
shift 2
;;
--block)
if [[ -z "${2:-}" ]]; then
echo "apply_chromium_extension_policy: --block requiere un ID de extensión" >&2
return 1
fi
block_ids+=("$2")
shift 2
;;
--policy-path)
if [[ -z "${2:-}" ]]; then
echo "apply_chromium_extension_policy: --policy-path requiere un valor" >&2
return 1
fi
policy_path="$2"
shift 2
;;
--update-url)
if [[ -z "${2:-}" ]]; then
echo "apply_chromium_extension_policy: --update-url requiere un valor" >&2
return 1
fi
update_url="$2"
shift 2
;;
--dry-run)
dry_run=1
shift
;;
*)
echo "apply_chromium_extension_policy: argumento desconocido: $1" >&2
echo "Uso: apply_chromium_extension_policy [--keep <ext_id>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]" >&2
return 1
;;
esac
done
# --- Validar que hay al menos una extensión a forzar o bloquear ---
if [[ ${#keep_ids[@]} -eq 0 && ${#block_ids[@]} -eq 0 ]]; then
echo "apply_chromium_extension_policy: se requiere al menos un --keep o un --block <ext_id>" >&2
return 1
fi
# --- Construir el JSON ---
# Dos claves complementarias, ninguna bloquea las extensiones unpacked (--load-extension),
# de modo que extensiones locales como la de captura de web_proxy siguen cargando:
# 1. ExtensionInstallForcelist: fuerza la instalación de la whitelist (--keep), que además
# no se puede desinstalar desde la UI.
# 2. ExtensionInstallBlocklist: bloquea Y desinstala las extensiones de la blocklist
# (--block) en cualquier perfil. Solo afecta a los IDs listados; el resto no se toca.
local forcelist_json="[]" blocklist_json="[]"
if [[ ${#keep_ids[@]} -gt 0 ]]; then
local entries="" first=1
for kid in "${keep_ids[@]}"; do
# Cada --keep puede ser "<id>" (usa el update_url por defecto, Web Store) o
# "<id>=<update_url>" para una extensión self-hosted (p.ej. file:// a un update.xml local).
local id="${kid%%=*}" url="$update_url"
[[ "$kid" == *=* ]] && url="${kid#*=}"
[[ $first -eq 0 ]] && entries+=","$'\n'
entries+=" \"${id};${url}\""
first=0
done
forcelist_json=$(printf '[\n%s\n ]' "$entries")
fi
if [[ ${#block_ids[@]} -gt 0 ]]; then
local entries="" first=1
for id in "${block_ids[@]}"; do
[[ $first -eq 0 ]] && entries+=","$'\n'
entries+=" \"${id}\""
first=0
done
blocklist_json=$(printf '[\n%s\n ]' "$entries")
fi
local new_json
new_json=$(cat <<JSONEOF
{
"ExtensionInstallForcelist": ${forcelist_json},
"ExtensionInstallBlocklist": ${blocklist_json}
}
JSONEOF
)
# --- Modo dry-run ---
if [[ $dry_run -eq 1 ]]; then
echo "[dry-run] JSON que se escribiría en: ${policy_path}"
echo "---"
echo "$new_json"
echo "---"
echo "[dry-run] No se ha modificado el sistema."
return 0
fi
# --- Idempotencia: comparar con contenido actual ---
if [[ -f "$policy_path" ]]; then
local current_content
current_content=$(cat "$policy_path" 2>/dev/null || true)
if [[ "$current_content" == "$new_json" ]]; then
echo "apply_chromium_extension_policy: política ya aplicada (sin cambios). Nada que hacer."
return 0
fi
fi
# --- Backup del archivo existente ---
# CRÍTICO: el backup NUNCA puede vivir dentro del directorio de la policy. Chromium lee TODOS
# los archivos del directorio managed/ (sin filtrar por extensión de nombre), así que un
# "extensions.json.bak.YYYYMMDD" dentro de managed/ se mergea con la policy efectiva y reinyecta
# las extensiones del backup. Por eso el backup se guarda en un directorio hermano (policy-backups)
# que chromium no lee.
local backup_path=""
if [[ -f "$policy_path" ]]; then
local date_suffix policy_dir backup_dir
date_suffix=$(date +%Y%m%d)
policy_dir="$(dirname "$policy_path")"
case "$(basename "$policy_dir")" in
managed|recommended) backup_dir="$(dirname "$policy_dir")/policy-backups" ;;
*) backup_dir="$policy_dir" ;;
esac
backup_path="${backup_dir}/$(basename "$policy_path").bak.${date_suffix}"
if [[ ! -d "$backup_dir" ]]; then
if [[ $EUID -ne 0 ]]; then sudo mkdir -p "$backup_dir" 2>/dev/null; else mkdir -p "$backup_dir"; fi
fi
if [[ ! -f "$backup_path" ]]; then
echo "apply_chromium_extension_policy: creando backup → ${backup_path}"
if [[ $EUID -ne 0 ]]; then
sudo cp "$policy_path" "$backup_path" || {
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
return 1
}
else
cp "$policy_path" "$backup_path" || {
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
return 1
}
fi
else
echo "apply_chromium_extension_policy: backup del día ya existe (${backup_path}), se omite."
fi
fi
# --- Crear directorio padre si no existe ---
local policy_dir
policy_dir=$(dirname "$policy_path")
if [[ ! -d "$policy_dir" ]]; then
echo "apply_chromium_extension_policy: creando directorio ${policy_dir}"
if [[ $EUID -ne 0 ]]; then
sudo mkdir -p "$policy_dir" || {
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
return 1
}
else
mkdir -p "$policy_dir" || {
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
return 1
}
fi
fi
# --- Escribir el JSON vía tmpfile + sudo cp ---
local tmpfile
tmpfile=$(mktemp /tmp/chromium_policy_XXXXXX.json)
echo "$new_json" > "$tmpfile"
if [[ $EUID -ne 0 ]]; then
sudo cp "$tmpfile" "$policy_path" || {
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
rm -f "$tmpfile"
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
echo "apply_chromium_extension_policy: restaurando backup tras error..."
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
fi
return 1
}
else
cp "$tmpfile" "$policy_path" || {
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
rm -f "$tmpfile"
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
echo "apply_chromium_extension_policy: restaurando backup tras error..."
cp "$backup_path" "$policy_path" 2>/dev/null || true
fi
return 1
}
fi
rm -f "$tmpfile"
# --- Validar el JSON escrito ---
local validation_ok=0
if command -v python3 &>/dev/null; then
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$policy_path" 2>/dev/null && validation_ok=1
elif command -v jq &>/dev/null; then
jq . "$policy_path" &>/dev/null && validation_ok=1
else
validation_ok=1
fi
if [[ $validation_ok -eq 0 ]]; then
echo "apply_chromium_extension_policy: el JSON escrito no es válido — restaurando backup" >&2
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
if [[ $EUID -ne 0 ]]; then
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
else
cp "$backup_path" "$policy_path" 2>/dev/null || true
fi
fi
return 1
fi
# --- Resumen final ---
echo "apply_chromium_extension_policy: política aplicada correctamente."
echo " Ruta : ${policy_path}"
if [[ ${#keep_ids[@]} -gt 0 ]]; then
echo " Forzadas (${#keep_ids[@]}):"
for id in "${keep_ids[@]}"; do echo " - ${id}"; done
fi
if [[ ${#block_ids[@]} -gt 0 ]]; then
echo " Bloqueadas/desinstaladas (${#block_ids[@]}):"
for id in "${block_ids[@]}"; do echo " - ${id}"; done
fi
echo " Extensiones unpacked (--load-extension, p.ej. web_proxy): NO afectadas."
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
echo " Backup : ${backup_path}"
fi
echo ""
echo " AVISO: Chromium cachea la politica en memoria. Para que surta efecto:"
echo " pkill -9 chromium (cierra TODOS los procesos)"
echo " # Luego relanza Chromium. O desde un Chromium abierto:"
echo " # chrome://policy → 'Reload policies'"
}
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
# solo se define la función y no se ejecuta nada.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
apply_chromium_extension_policy "$@"
fi
@@ -0,0 +1,79 @@
---
name: backup_chrome_bookmarks
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]... [--backup-dir <dir>] [--dry-run]"
description: "Copia byte a byte los archivos Bookmarks de perfiles Chrome/Chromium a un directorio de backup con timestamp ISO. Descubre automáticamente todos los perfiles con Bookmarks si no se especifica ninguno. Preserva el checksum interno del archivo sin parsear ni reserializar el JSON. No requiere que Chromium esté cerrado."
tags: [navegator, chromium, bookmarks, backup, browser, scraping]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/backup_chrome_bookmarks.sh"
params:
- name: --user-data-dir
desc: "(obligatorio) Ruta raíz del user-data-dir de Chrome/Chromium. Ej: ~/.config/chromium-cdp"
- name: --profile
desc: "Nombre de carpeta de perfil a respaldar (repetible). Si no se pasa ninguno se descubren todos los perfiles que contengan un archivo Bookmarks, excluyendo System Profile."
- name: --backup-dir
desc: "Directorio raíz donde se crearán los backups. Default: ~/.local/share/web_scraping/bookmarks-backups"
- name: --dry-run
desc: "Muestra a stderr qué archivos se copiarían y sus tamaños sin escribir nada en disco. El JSON de salida se emite igualmente."
output: "JSON en stdout: {backup_dir: \"<dir>\", ts: \"<YYYYMMDDTHHmmss>\", profiles: [{profile: \"<name>\", src: \"<path>\", dst: \"<path>\", bytes: N}, ...]}. Perfiles sin Bookmarks se omiten silenciosamente. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# Backup de todos los perfiles del chromium-cdp (descubrimiento automático)
source $HOME/fn_registry/bash/functions/browser/backup_chrome_bookmarks.sh
backup_chrome_bookmarks --user-data-dir "$HOME/.config/chromium-cdp"
# Previsualizar sin tocar nada
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--dry-run
# Backup de perfiles específicos
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--profile Default \
--profile Personal \
--profile "Profile 1"
# Backup a directorio personalizado
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--backup-dir "$HOME/vaults/backups/bookmarks"
# Salida esperada (ejemplo):
# {"backup_dir":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups","ts":"20260605T143022","profiles":[{"profile":"Default","src":"/home/enmanuel/.config/chromium-cdp/Default/Bookmarks","dst":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups/20260605T143022/Default/Bookmarks","bytes":4218}]}
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run backup_chrome_bookmarks_bash_browser -- \
--user-data-dir "$HOME/.config/chromium-cdp" --dry-run
```
## Cuando usarla
Úsala antes de cualquier sesión de scraping o automatización que modifique bookmarks de Chromium, para tener un snapshot recuperable. También útil como paso previo en pipelines que reorganizan o importan marcadores masivamente. Combínala con `rotate_backups_bash_infra` para aplicar política de retención sobre el directorio de backups.
## Gotchas
- **Copia verbatim para preservar checksum**: el archivo `Bookmarks` de Chromium incluye un campo `checksum` calculado sobre el contenido. Esta función usa `cp -p` sin tocar el contenido — si parseases y reserializases el JSON (con `jq`, `python3 json.dump`, etc.) el checksum quedaría inválido y Chromium podría resetear o ignorar los marcadores al arrancar.
- **No requiere Chromium cerrado**: a diferencia de `clean_chrome_profile_extensions`, esta función solo lee el archivo `Bookmarks`. Chromium no mantiene un lock exclusivo sobre él — la copia es segura con el navegador abierto. El archivo refleja el estado en disco en ese instante; cambios en vuelo en memoria no estarán en el backup hasta que Chromium los persista.
- **Perfiles sin Bookmarks se omiten silenciosamente**: si un perfil existe pero no tiene el archivo `Bookmarks` (perfil recién creado sin haber abierto el navegador), se salta sin error. Solo aparece en el JSON de salida si fue respaldado.
- **System Profile excluido siempre**: el perfil `System Profile` es un perfil interno de Chromium sin datos de usuario y se excluye del descubrimiento automático.
- **Sin jq ni python3**: la emisión del JSON de salida se construye con printf de bash puro, sin dependencias externas.
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# backup_chrome_bookmarks — copia byte a byte los archivos Bookmarks de perfiles
# Chrome/Chromium a un directorio de backup con timestamp. Preserva el checksum
# interno del archivo sin parsear ni reserializar el JSON.
set -euo pipefail
backup_chrome_bookmarks() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir=""
local _profiles=()
local _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]...
[--backup-dir <dir>] [--dry-run]
--user-data-dir (obligatorio) Raíz de perfiles de Chrome/Chromium.
Ej: ~/.config/chromium-cdp
--profile <name> Nombre de carpeta de perfil a respaldar (repetible).
Si no se pasa ninguno → respalda TODOS los perfiles con
un archivo Bookmarks (excluye System Profile).
--backup-dir <dir> Directorio raíz para backups.
Default: ~/.local/share/web_scraping/bookmarks-backups
--dry-run Muestra qué copiaría sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "backup_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones ──────────────────────────────────────────────────────────
if [[ -z "$_user_data_dir" ]]; then
echo "backup_chrome_bookmarks: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "backup_chrome_bookmarks: directorio no encontrado: ${_user_data_dir}" >&2
return 1
fi
# ── descubrir perfiles si no se pasó ninguno ───────────────────────────────
if [[ ${#_profiles[@]} -eq 0 ]]; then
local _candidate
while IFS= read -r -d '' _candidate; do
local _pname
_pname="$(basename "$_candidate")"
# Excluir System Profile (perfil interno de Chromium sin datos de usuario)
if [[ "$_pname" == "System Profile" ]]; then
continue
fi
if [[ -f "${_candidate}/Bookmarks" ]]; then
_profiles+=("$_pname")
fi
done < <(find "$_user_data_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
echo "backup_chrome_bookmarks: no se encontraron perfiles con archivo Bookmarks en: ${_user_data_dir}" >&2
return 1
fi
# ── timestamp único para este backup ──────────────────────────────────────
local _ts
_ts="$(date +%Y%m%dT%H%M%S)"
# ── procesar perfiles ─────────────────────────────────────────────────────
# Construir el array de resultados JSON manualmente (sin jq ni python3)
local _results="["
local _first=1
local _profile
for _profile in "${_profiles[@]}"; do
local _src="${_user_data_dir}/${_profile}/Bookmarks"
# Si el perfil no tiene Bookmarks, se omite sin error
if [[ ! -f "$_src" ]]; then
continue
fi
local _dst="${_backup_dir}/${_ts}/${_profile}/Bookmarks"
local _bytes
_bytes="$(wc -c < "$_src")"
if [[ $_dry_run -eq 1 ]]; then
echo "backup_chrome_bookmarks: [dry-run] cp -p \"${_src}\" -> \"${_dst}\" (${_bytes} bytes)" >&2
else
local _dst_dir
_dst_dir="$(dirname "$_dst")"
mkdir -p "$_dst_dir"
cp -p "$_src" "$_dst"
fi
# Escapar comillas dobles en el path por si acaso
local _src_esc="${_src//\"/\\\"}"
local _dst_esc="${_dst//\"/\\\"}"
local _profile_esc="${_profile//\"/\\\"}"
local _entry
_entry="$(printf '{"profile":"%s","src":"%s","dst":"%s","bytes":%s}' \
"$_profile_esc" "$_src_esc" "$_dst_esc" "$_bytes")"
if [[ $_first -eq 1 ]]; then
_results+="$_entry"
_first=0
else
_results+=",${_entry}"
fi
done
_results+="]"
# ── emitir resultado JSON ──────────────────────────────────────────────────
local _backup_dir_esc="${_backup_dir//\"/\\\"}"
printf '{"backup_dir":"%s","ts":"%s","profiles":%s}\n' \
"$_backup_dir_esc" "$_ts" "$_results"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
backup_chrome_bookmarks "$@"
fi
@@ -0,0 +1,84 @@
---
name: clean_chrome_profile_extensions
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>] [--keep <ext_id>]... [--dry-run]"
description: "Purga in-place las extensiones de un perfil Chrome/Chromium existente que no estén en la whitelist --keep: borra sus carpetas de disco y elimina sus referencias de Preferences y Secure Preferences para que Chromium no las reinstale. Complementaria a apply_chromium_extension_policy_bash_browser que evita reinstalación pero no desinstala lo ya instalado en Chromium 148."
tags: [navegator, chromium, extensions, profile, cleanup, browser, scraping]
uses_functions: [apply_chromium_extension_policy_bash_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/clean_chrome_profile_extensions.sh"
params:
- name: --user-data-dir
desc: "Ruta raíz del user-data-dir de Chrome/Chromium. Default: ~/.config/chromium"
- name: --profile-directory
desc: "Nombre del subperfil dentro de user-data-dir. Default: Default"
- name: --keep
desc: "ID de extensión Chrome a conservar (repetible, 32 chars minúsculas). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
- name: --dry-run
desc: "Muestra qué IDs se conservarían y cuáles se borrarían sin tocar disco ni archivos de preferencias"
output: "JSON en stdout: {profile: \"<path>\", kept: [id...], removed: [id...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# Cerrar Chromium primero (OBLIGATORIO en modo real)
pkill -TERM chromium
# Purgar perfil Default dejando solo uBlock Origin Lite
source $HOME/fn_registry/bash/functions/browser/clean_chrome_profile_extensions.sh
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh
# Previsualizar antes de tocar nada
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
# Perfil no-default con whitelist de dos extensiones
clean_chrome_profile_extensions \
--user-data-dir "$HOME/.config/chromium" \
--profile-directory "Profile 1" \
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
--keep cjpalhdlnbpafiamejdnhcphjbkeiagm
# Salida esperada (ejemplo):
# {"profile":"/home/enmanuel/.config/chromium/Default","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["dark-reader-id","another-ext-id"]}
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run clean_chrome_profile_extensions_bash_browser -- --dry-run
```
## Cuando usarla
Úsala después de reducir la whitelist de extensiones con `apply_chromium_extension_policy_bash_browser` (modo `blocked`), para quitar del disco las que ya estaban instaladas en el perfil: la policy evita que Chromium reinstale extensiones nuevas, pero en Chromium 148 no desinstala las que ya estaban force-instaladas. Esta función hace la purga determinista del estado existente. También útil antes de una sesión de scraping para dejar el perfil con solo las extensiones necesarias.
## Gotchas
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium reescribe `Preferences` desde memoria al cerrar y desharía toda la purga. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` no se hace este check.
- **Combínala con `apply_chromium_extension_policy_bash_browser` (blocked)** para que las extensiones no vuelvan a instalarse la próxima vez que arranques Chromium. Esta función purga el estado actual; la policy evita la reinstalación futura.
- **Backup automático de prefs**: antes de editar `Preferences` y `Secure Preferences` la función crea `<archivo>.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. En caso de problemas: `cp Preferences.bak.YYYYMMDD Preferences`.
- **Opera por perfil**: actúa sobre `--user-data-dir`/`--profile-directory`/Extensions. Si tienes varios perfiles (`Default`, `Profile 1`, etc.) debes invocarla una vez por cada uno.
- **python3 > jq > warn**: para editar el JSON de Preferences usa python3 si está disponible, jq como fallback, y emite un warning a stderr (sin abortar) si ninguno está. En ese caso las carpetas sí se borran pero las referencias en Preferences quedan — Chromium podría intentar reinstalar desde Web Store.
- **Secure Preferences HMAC**: la tabla `protection.macs.extensions.settings` también se limpia para evitar que Chromium detecte inconsistencia entre el HMAC y la entrada eliminada y resetee configuraciones. Si la HMAC falla de todas formas, Chromium lo trata como perfil potencialmente corrupto y puede resetear algunas prefs — comportamiento esperado de Chromium, no un bug de esta función.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito o dry-run completado |
| 1 | Argumento inválido o perfil no encontrado |
| 2 | Chromium está corriendo (solo en modo real) |
| 3 | Directorio Extensions no encontrado |
@@ -0,0 +1,245 @@
#!/usr/bin/env bash
# clean_chrome_profile_extensions — purga in-place extensiones fuera de la whitelist
# de un perfil Chrome/Chromium existente. Borra las carpetas de disco y limpia las
# referencias en Preferences y Secure Preferences para que Chromium no las reinstale.
set -euo pipefail
clean_chrome_profile_extensions() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir="${HOME}/.config/chromium"
local _profile_dir="Default"
local _keep=()
local _default_ext="ddkjiahejlhfcafbddmgiahcphecmpfh"
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>]
[--keep <ext_id>]... [--dry-run]
--user-data-dir Raíz del perfil. Default: ~/.config/chromium
--profile-directory Subperfil. Default: Default
--keep <ext_id> ID de extensión a conservar (repetible).
Default si no se pasa ninguno: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)
--dry-run Lista qué se borraría sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
2 chromium está corriendo (solo en modo real)
3 directorio de extensiones no encontrado
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--profile-directory) _profile_dir="$2"; shift 2 ;;
--keep) _keep+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "clean_chrome_profile_extensions: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── whitelist por defecto ──────────────────────────────────────────────────
if [[ ${#_keep[@]} -eq 0 ]]; then
_keep=("$_default_ext")
fi
# ── construir paths base ───────────────────────────────────────────────────
local _profile_path
_profile_path="${_user_data_dir}/${_profile_dir}"
local _ext_dir="${_profile_path}/Extensions"
# ── validaciones ──────────────────────────────────────────────────────────
if [[ ! -d "$_profile_path" ]]; then
echo "clean_chrome_profile_extensions: perfil no encontrado: ${_profile_path}" >&2
return 1
fi
if [[ ! -d "$_ext_dir" ]]; then
echo "clean_chrome_profile_extensions: directorio de extensiones no encontrado: ${_ext_dir}" >&2
return 3
fi
# ── guard: chromium NO debe estar corriendo (excepto en dry-run) ──────────
if [[ $_dry_run -eq 0 ]]; then
if pgrep -x chromium >/dev/null 2>&1; then
echo "clean_chrome_profile_extensions: chromium está corriendo — ciérralo antes de limpiar:" >&2
echo " pkill -TERM chromium" >&2
echo "(Chromium reescribe Preferences desde memoria al cerrar y desharía la purga)" >&2
return 2
fi
fi
# ── enumerar extensiones instaladas ───────────────────────────────────────
local _to_remove=()
local _to_keep=()
while IFS= read -r -d '' _ext_path; do
local _ext_id
_ext_id="$(basename "$_ext_path")"
# Siempre ignorar la carpeta Temp (usada durante installs en curso)
if [[ "$_ext_id" == "Temp" ]]; then
continue
fi
# Comprobar si está en la whitelist
local _in_keep=0
local _k
for _k in "${_keep[@]}"; do
if [[ "$_ext_id" == "$_k" ]]; then
_in_keep=1
break
fi
done
if [[ $_in_keep -eq 1 ]]; then
_to_keep+=("$_ext_id")
else
_to_remove+=("$_ext_id")
fi
done < <(find "$_ext_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
# ── modo dry-run: solo informar ────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== clean_chrome_profile_extensions DRY-RUN ===" >&2
echo " Perfil : ${_profile_path}" >&2
echo " Conservar (${#_to_keep[@]}): ${_to_keep[*]+"${_to_keep[*]}"}" >&2
echo " Borrar (${#_to_remove[@]}): ${_to_remove[*]+"${_to_remove[*]}"}" >&2
_emit_json "$_profile_path" _to_keep _to_remove
return 0
fi
# ── borrar extensiones fuera de la whitelist ───────────────────────────────
if [[ ${#_to_remove[@]} -gt 0 ]]; then
local _id
for _id in "${_to_remove[@]}"; do
rm -rf "${_ext_dir}/${_id}"
done
# ── purgar referencias en Preferences y Secure Preferences ────────────
# Construir lista Python de IDs eliminados
local _py_ids_list=""
for _id in "${_to_remove[@]}"; do
_py_ids_list+="\"${_id}\","
done
_py_ids_list="[${_py_ids_list%,}]"
local _today
_today="$(date +%Y%m%d)"
local _prefs_file
for _prefs_file in "${_profile_path}/Preferences" "${_profile_path}/Secure Preferences"; do
if [[ ! -f "$_prefs_file" ]]; then
continue
fi
# Backup (no sobreescribir backup del mismo día)
local _backup="${_prefs_file}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_prefs_file" "$_backup"
fi
# Editar con python3 si está disponible
if command -v python3 >/dev/null 2>&1; then
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
echo "clean_chrome_profile_extensions: advertencia — no se pudo purgar ${_prefs_file} con python3" >&2
import sys, json
prefs_path = sys.argv[1]
removed_ids = json.loads(sys.argv[2])
with open(prefs_path, "r", encoding="utf-8") as f:
data = json.load(f)
# 1. extensions.settings.<id>
ext_settings = data.get("extensions", {}).get("settings", {})
for ext_id in removed_ids:
ext_settings.pop(ext_id, None)
# 2. extensions.pinned_extensions (lista de IDs)
pinned = data.get("extensions", {}).get("pinned_extensions", None)
if isinstance(pinned, list):
data["extensions"]["pinned_extensions"] = [
pid for pid in pinned if pid not in removed_ids
]
# 3. protection.macs.extensions.settings.<id> (Secure Preferences HMAC table)
try:
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
for ext_id in removed_ids:
mac_ext.pop(ext_id, None)
except (KeyError, TypeError):
pass
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
# Fallback con jq si python3 no está disponible
elif command -v jq >/dev/null 2>&1; then
local _tmp_prefs
_tmp_prefs="$(mktemp)"
local _jq_del=""
for _id in "${_to_remove[@]}"; do
_jq_del+=" | del(.extensions.settings[\"${_id}\"])"
_jq_del+=" | del(.protection.macs.extensions.settings[\"${_id}\"])"
done
# pinned_extensions como lista
_jq_del+=" | if .extensions.pinned_extensions then .extensions.pinned_extensions -= [$(printf '"%s",' "${_to_remove[@]}" | sed 's/,$//')] else . end"
jq "${_jq_del:1}" "$_prefs_file" > "$_tmp_prefs" && mv "$_tmp_prefs" "$_prefs_file" || {
echo "clean_chrome_profile_extensions: advertencia — jq falló procesando ${_prefs_file}" >&2
rm -f "$_tmp_prefs"
}
else
echo "clean_chrome_profile_extensions: advertencia — ni python3 ni jq disponibles; se borraron las carpetas pero no las referencias en $(basename "$_prefs_file")" >&2
fi
done
fi
# ── emitir resultado JSON ──────────────────────────────────────────────────
_emit_json "$_profile_path" _to_keep _to_remove
}
# ── helpers ────────────────────────────────────────────────────────────────────
# _json_array_from_nameref <nameref>
# Convierte un array bash (pasado por nombre de variable) en JSON array de strings.
_json_array_from_nameref() {
local -n _arr_ref="$1"
local _out="["
local _first=1
local _item
for _item in "${_arr_ref[@]+"${_arr_ref[@]}"}"; do
if [[ $_first -eq 1 ]]; then
_out+="\"${_item}\""
_first=0
else
_out+=",\"${_item}\""
fi
done
_out+="]"
echo "$_out"
}
# _emit_json <profile_path> <kept_nameref> <removed_nameref>
_emit_json() {
local _p="$1"
local _kept_json
_kept_json="$(_json_array_from_nameref "$2")"
local _removed_json
_removed_json="$(_json_array_from_nameref "$3")"
printf '{"profile":"%s","kept":%s,"removed":%s}\n' \
"$_p" "$_kept_json" "$_removed_json"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
clean_chrome_profile_extensions "$@"
fi
@@ -0,0 +1,93 @@
---
name: create_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible> [--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]"
description: "Crea un perfil Chrome/Chromium nuevo en un user-data-dir: opcionalmente lanza chromium headless vía systemd-run para que la managed policy instale las extensiones forzadas (uBlock, web_proxy) y luego edita Local State para asignar el nombre legible al perfil. Con --no-launch crea solo la estructura de carpetas y la entrada en Local State sin arrancar Chrome."
tags: [navegator, chromium, profile, browser, cdp, headless, scraping]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/create_chrome_profile.sh"
params:
- name: --user-data-dir
desc: "Raíz del user-data-dir de Chrome/Chromium. Puede no existir; la función lo crea. Obligatorio."
- name: --profile
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, \"Profile 1\", Automation. Obligatorio."
- name: --name
desc: "Nombre legible visible en el selector de perfil de Chrome, por ejemplo: Work, Aurgi, Bot. Obligatorio."
- name: --port
desc: "Puerto CDP para el lanzamiento headless. Default: 9250. Usar un valor distinto al 9222 global para no colisionar."
- name: --chrome-path
desc: "Ruta absoluta al binario chromium/chrome. Si se omite, auto-detecta: chromium, chromium-browser, google-chrome, brave-browser."
- name: --no-launch
desc: "No lanza chromium. Solo crea la carpeta del perfil y edita Local State con el nombre legible. El perfil no tendrá extensiones instaladas. Útil para tests y CRUD offline."
- name: --timeout-sec
desc: "Segundos máximos esperando a que Preferences aparezca tras el lanzamiento headless. Default: 25."
- name: --dry-run
desc: "Describe las acciones que se ejecutarían sin lanzar ni escribir nada. Emite el JSON de resultado con dry_run:true."
output: "JSON en stdout: {\"profile\":\"<dir-name>\",\"name\":\"<legible>\",\"launched\":true|false,\"preferences_created\":true|false}. En dry-run añade \"dry_run\":true. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/create_chrome_profile.sh
# Modo offline (no lanza Chrome, solo CRUD de Local State — seguro para tests)
create_chrome_profile \
--user-data-dir /tmp/test_udd \
--profile "Automation" \
--name "Aurgi Bot" \
--no-launch
# Salida: {"profile":"Automation","name":"Aurgi Bot","launched":false,"preferences_created":false}
# Modo normal: lanza headless para que la policy instale uBlock y web_proxy,
# luego asigna nombre en Local State
create_chrome_profile \
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
--profile "Profile 1" \
--name "Work" \
--port 9250
# Salida: {"profile":"Profile 1","name":"Work","launched":true,"preferences_created":true}
# Dry-run: describe acciones sin ejecutar nada
create_chrome_profile \
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
--profile "Default" \
--name "Scraping" \
--dry-run
```
## Cuando usarla
Úsala para aprovisionar perfiles nuevos en un user-data-dir de automatización antes de lanzar sesiones CDP con `script-navegador` o funciones del grupo `navegator`. En modo normal (sin `--no-launch`) la managed policy instala automáticamente uBlock y la extensión web_proxy en el perfil nuevo; en `--no-launch` sirve para tests unitarios o para crear la entrada de Local State sin depender de Chrome.
## Gotchas
- **Lanzar chromium desde Bash tool de Claude da exit-144**: la función usa `systemd-run --user --collect` para aislar el proceso en su propio cgroup, evitando que el harness del agente lo mate. Esto es obligatorio; lanzar con `&` / `setsid` daría exit-144 en el contexto del agente.
- **La managed policy instala las extensiones al arrancar el perfil**: NO pasar `--disable-extensions` — rompería la forcelist. Las extensiones force-listed (`ExtensionInstallForcelist` en `/etc/chromium/policies/managed/extensions.json`) se instalan en el perfil durante el primer arranque; en el headless inicial puede no completar la descarga si no hay red o si el timeout es corto.
- **Dos chromium NO pueden compartir el mismo user-data-dir**: si ya hay un chromium corriendo sobre `--user-data-dir`, la función detecta `SingletonLock` y sale con exit 2 antes de lanzar. Para perfiles de automatización paralela, usa un `--user-data-dir` dedicado por perfil.
- **Local State debe editarse con Chrome muerto**: la función para el unit de systemd y espera la desaparición de `SingletonLock` antes de editar `Local State`. Si se edita mientras Chrome está vivo, Chrome sobreescribe el archivo desde memoria al salir y los cambios de nombre se pierden.
- **`--remote-allow-origins=*` necesita comillas en zsh**: el glob `*` se expande si no va entre comillas. La función pasa el flag correctamente internamente, pero si lo pasas tú en otros scripts acuérdate de las comillas.
- **Perfil diario en `~/.config/chromium-cdp`**: en este equipo el fragmento `/etc/chromium.d/cdp` redirige el user-data-dir global a `~/.config/chromium-cdp`. Para automatización usar siempre un `--user-data-dir` dedicado fuera de `~/.config/`.
- **Timeout corto puede dar `preferences_created: false`**: el perfil headless tarda entre 2-8 segundos en crear `Preferences` según la carga del sistema. Si se aumenta `--timeout-sec` a 45-60 en máquinas lentas se evitan falsos timeouts.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento obligatorio faltante o binario no encontrado |
| 2 | Lock: ya hay un chromium usando el mismo user-data-dir |
| 3 | Timeout esperando a que Preferences se cree |
| 4 | Error editando Local State (JSON inválido tras escritura) |
@@ -0,0 +1,309 @@
#!/usr/bin/env bash
# create_chrome_profile — crea un perfil Chrome/Chromium nuevo en un user-data-dir,
# opcionalmente lanzando chromium headless para que la managed policy instale las
# extensiones forzadas (uBlock, web_proxy). Edita Local State para asignar el nombre
# legible al perfil.
set -euo pipefail
create_chrome_profile() {
# ── defaults ──────────────────────────────────────────────────────────────
local _udd=""
local _profile_dir=""
local _name=""
local _port=9250
local _chrome_path=""
local _no_launch=0
local _timeout_sec=25
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible>
[--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile Nombre de la carpeta del perfil dentro de user-data-dir, ej: Default,
"Profile 1", Automation (obligatorio).
--name Nombre legible que aparece en el selector de perfil, ej: Work, Aurgi
(obligatorio).
--port Puerto CDP para el lanzamiento headless. Default: 9250.
Usar un puerto distinto al 9222 global para no chocar.
--chrome-path Ruta explícita al binario chromium/chrome. Auto-detecta si se omite.
--no-launch No lanza chromium. Crea la carpeta y edita Local State offline.
El perfil no tendrá extensiones instaladas; útil para tests/CRUD.
--timeout-sec Segundos esperando a que Preferences aparezca tras el lanzamiento.
Default: 25.
--dry-run Describe las acciones sin lanzar ni escribir nada.
Exit codes:
0 éxito
1 error de argumento o validación
2 lock: ya hay un chromium usando este user-data-dir
3 timeout esperando a que Preferences se cree
4 error editando Local State (JSON inválido tras escritura)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _udd="$2"; shift 2 ;;
--profile) _profile_dir="$2"; shift 2 ;;
--name) _name="$2"; shift 2 ;;
--port) _port="$2"; shift 2 ;;
--chrome-path) _chrome_path="$2"; shift 2 ;;
--no-launch) _no_launch=1; shift ;;
--timeout-sec) _timeout_sec="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "create_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones obligatorias ──────────────────────────────────────────────
if [[ -z "$_udd" ]]; then
echo "create_chrome_profile: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ -z "$_profile_dir" ]]; then
echo "create_chrome_profile: --profile es obligatorio" >&2
return 1
fi
if [[ -z "$_name" ]]; then
echo "create_chrome_profile: --name es obligatorio" >&2
return 1
fi
local _profile_path="${_udd}/${_profile_dir}"
local _local_state="${_udd}/Local State"
local _prefs_file="${_profile_path}/Preferences"
# ── guard: lock por user-data-dir ─────────────────────────────────────────
# Dos procesos chromium no pueden compartir el mismo user-data-dir.
if [[ $_dry_run -eq 0 && $_no_launch -eq 0 ]]; then
local _singleton="${_udd}/SingletonLock"
if [[ -e "$_singleton" ]]; then
echo "create_chrome_profile: ya hay un chromium corriendo con --user-data-dir=${_udd}" >&2
echo " (encontrado: ${_singleton})" >&2
echo " Ciérralo o usa un user-data-dir distinto." >&2
return 2
fi
fi
# ── detección del binario chromium ────────────────────────────────────────
local _bin=""
if [[ -n "$_chrome_path" ]]; then
if [[ ! -x "$_chrome_path" ]]; then
echo "create_chrome_profile: binario no encontrado o no ejecutable: ${_chrome_path}" >&2
return 1
fi
_bin="$_chrome_path"
elif [[ $_no_launch -eq 0 ]]; then
for _candidate in chromium chromium-browser google-chrome brave-browser; do
if command -v "$_candidate" &>/dev/null; then
_bin="$_candidate"
break
fi
done
if [[ -z "$_bin" ]]; then
echo "create_chrome_profile: no se encontró binario chromium en PATH" >&2
echo " Probados: chromium, chromium-browser, google-chrome, brave-browser" >&2
echo " Usa --chrome-path o --no-launch." >&2
return 1
fi
fi
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== create_chrome_profile DRY-RUN ===" >&2
echo " user-data-dir : ${_udd}" >&2
echo " profile : ${_profile_dir}" >&2
echo " name : ${_name}" >&2
if [[ $_no_launch -eq 1 ]]; then
echo " modo : --no-launch (sin chromium)" >&2
echo " acciones : mkdir -p ${_profile_path}" >&2
echo " editar ${_local_state} → info_cache + profiles_order" >&2
else
echo " binario : ${_bin}" >&2
echo " puerto CDP : ${_port}" >&2
echo " timeout : ${_timeout_sec}s" >&2
echo " acciones : systemd-run unit=create-prof-<rand> chromium headless" >&2
echo " poll Preferences hasta ${_timeout_sec}s" >&2
echo " systemctl --user stop unit" >&2
echo " editar ${_local_state} → info_cache + profiles_order" >&2
fi
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":false,"dry_run":true}\n' \
"$_profile_dir" "$_name"
return 0
fi
# ── crear directorio del perfil ───────────────────────────────────────────
mkdir -p "$_profile_path"
# ── también asegurar que user-data-dir existe ──────────────────────────────
mkdir -p "$_udd"
# ── modo --no-launch: solo estructura + Local State ────────────────────────
local _launched=false
local _prefs_created=false
if [[ $_no_launch -eq 1 ]]; then
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
if [[ -f "$_prefs_file" ]]; then
_prefs_created=true
fi
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":%s}\n' \
"$_profile_dir" "$_name" "$_prefs_created"
return 0
fi
# ── lanzar chromium headless vía systemd-run ──────────────────────────────
# systemd-run --user aísla el proceso del cgroup del agente (evita exit-144).
# NO se pasa --disable-extensions para que la managed policy instale las
# extensiones force-listed (uBlock, web_proxy).
local _rand
_rand="$(tr -dc 'a-z0-9' </dev/urandom | head -c 8 2>/dev/null || echo "$$")"
local _unit="create-prof-${_rand}"
systemd-run \
--user \
--collect \
--unit="$_unit" \
--setenv=DISPLAY=:0 \
--setenv=XAUTHORITY="${HOME}/.Xauthority" \
"$_bin" \
"--user-data-dir=${_udd}" \
"--profile-directory=${_profile_dir}" \
"--headless=new" \
"--no-first-run" \
"--remote-debugging-port=${_port}" \
"--remote-allow-origins=*" \
"about:blank" 2>/dev/null || true
_launched=true
# ── poll: esperar a que Preferences exista ────────────────────────────────
local _elapsed=0
while [[ $_elapsed -lt $_timeout_sec ]]; do
if [[ -f "$_prefs_file" ]]; then
_prefs_created=true
break
fi
sleep 1
(( _elapsed++ )) || true
done
# ── detener el unit Y matar TODO el árbol de chromium de este udd ───────────
# Necesario para poder editar Local State sin que Chrome lo sobreescriba. Ni el
# `systemctl stop` ni un `pkill -f --user-data-dir=` bastan: los procesos hijos
# (zygote/gpu/renderer) no repiten el flag --user-data-dir pero sí referencian la
# ruta del user-data-dir en otros argumentos. Los matamos por PID seleccionando
# los procesos chromium cuyo cmdline contiene la ruta del udd (seguro: no mata
# este propio script porque filtramos por '[c]hromium').
systemctl --user kill -s SIGKILL "$_unit" 2>/dev/null || true
systemctl --user stop "$_unit" 2>/dev/null || true
# Matar por PID los procesos cuyo comm es exactamente "chromium" (pgrep -x) y cuyo cmdline
# contiene la ruta del udd. Usamos pgrep -x para NO auto-matchear grep/pgrep: el path del udd
# contiene la cadena "chromium" (~/.config/chromium-cdp).
local _wait=0 _p _pids
while :; do
_pids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _pids="$_pids $_p"
done
[[ -z "${_pids// }" ]] && break
# shellcheck disable=SC2086
kill -TERM $_pids 2>/dev/null || true
sleep 0.5
(( _wait++ )) || true
if [[ $_wait -ge 20 ]]; then
# shellcheck disable=SC2086
kill -9 $_pids 2>/dev/null || true
break
fi
done
rm -f "${_udd}/SingletonLock" 2>/dev/null || true
if [[ "$_prefs_created" == false ]]; then
echo "create_chrome_profile: timeout (${_timeout_sec}s) esperando a que se cree: ${_prefs_file}" >&2
echo " El directorio del perfil puede existir pero está vacío." >&2
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":false,"error":"timeout"}\n' \
"$_profile_dir" "$_name"
return 3
fi
# ── editar Local State para asignar nombre legible ────────────────────────
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":true}\n' \
"$_profile_dir" "$_name"
}
# ── helper: editar Local State con python3 ────────────────────────────────────
# Crea/actualiza info_cache.<profile_dir> con name + is_using_default_name=false
# y añade profile_dir a profiles_order si no está.
_update_local_state() {
local _udd="$1"
local _local_state="$2"
local _profile_dir="$3"
local _name="$4"
local _today
_today="$(date +%Y%m%d)"
# Si Local State no existe, crear una estructura mínima
if [[ ! -f "$_local_state" ]]; then
printf '{"profile":{"info_cache":{},"profiles_order":[]}}\n' > "$_local_state"
fi
# Backup antes de modificar (no sobreescribir el del mismo día)
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# Editar con python3
if ! python3 - "$_local_state" "$_profile_dir" "$_name" <<'PY'; then
import sys, json
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
prof_name = sys.argv[3]
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Asegurar estructura profile
profile_section = data.setdefault("profile", {})
info_cache = profile_section.setdefault("info_cache", {})
# Crear o actualizar la entrada del perfil en info_cache
entry = info_cache.setdefault(prof_dir, {})
entry["name"] = prof_name
entry["is_using_default_name"] = False
# Añadir a profiles_order si no está
order = profile_section.setdefault("profiles_order", [])
if prof_dir not in order:
order.append(prof_dir)
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "create_chrome_profile: error editando Local State con python3" >&2
return 4
fi
# Validar JSON tras escritura
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "create_chrome_profile: JSON inválido tras escribir Local State; restaurando backup" >&2
cp "$_backup" "$_local_state"
return 4
fi
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
create_chrome_profile "$@"
fi
@@ -0,0 +1,93 @@
---
name: delete_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]"
description: "Borra por completo uno o varios perfiles Chrome/Chromium: elimina la carpeta del perfil del disco y limpia todas sus referencias en Local State (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups). Requiere que Chromium esté cerrado. Hace backup automático de Local State antes de editar y valida el JSON resultante restaurando el backup si es inválido."
tags: [navegator, chromium, profile, cleanup, browser, scraping]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/delete_chrome_profile.sh"
params:
- name: --user-data-dir
desc: "Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio). Ej: ~/.config/chromium"
- name: --profile
desc: "Nombre de la carpeta del perfil a borrar (repetible, mínimo uno obligatorio). Ej: 'Default', 'Profile 1'"
- name: --dry-run
desc: "Muestra qué carpetas borraría y qué claves de Local State quitaría sin tocar nada. No activa el guard de chromium cerrado."
output: "JSON en stdout. Modo real: {deleted:[{profile, dir_removed, local_state_cleaned}...], last_used:'<nuevo>', backup:'Local State.bak.YYYYMMDD'}. Modo dry-run: {dry_run:true, would_delete:[{profile, dir_exists, would_remove, local_state_would_clean}...]}. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# Cerrar Chromium primero (OBLIGATORIO en modo real)
pkill -TERM chromium
# Borrar un perfil
source $HOME/fn_registry/bash/functions/browser/delete_chrome_profile.sh
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1"
# Salida: {"deleted":[{"profile":"Profile 1","dir_removed":true,"local_state_cleaned":true}],"last_used":"Default","backup":"Local State.bak.20260606"}
# Borrar varios perfiles a la vez
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1" \
--profile "Profile 2"
# Previsualizar sin tocar nada (no requiere Chromium cerrado)
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1" \
--dry-run
# Salida: {"dry_run":true,"would_delete":[{"profile":"Profile 1","dir_exists":true,"would_remove":true,"local_state_would_clean":true}]}
# Con un user-data-dir sintético para pruebas
mkdir -p /tmp/test_udd/Default /tmp/test_udd/"Profile 1"
echo '{"profile":{"info_cache":{"Default":{},"Profile 1":{}},"profiles_order":["Default","Profile 1"],"last_active_profiles":["Profile 1"],"last_used":"Profile 1"},"variations_google_groups":{}}' \
> "/tmp/test_udd/Local State"
delete_chrome_profile --user-data-dir /tmp/test_udd --profile "Profile 1" --dry-run
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run delete_chrome_profile_bash_browser -- \
--user-data-dir "$HOME/.config/chromium" --profile "Profile 1" --dry-run
```
## Cuando usarla
Úsala cuando necesites limpiar completamente un perfil de Chromium: antes de crear un perfil de scraping fresco, para depurar problemas de perfiles corruptos, o para liberar espacio eliminando perfiles de sesión temporales. A diferencia de borrar solo la carpeta, esta función también retira las referencias de `Local State` para que Chromium no muestre el perfil fantasma ni intente acceder a él al arrancar.
## Gotchas
- **Chromium DEBE estar cerrado antes de ejecutar en modo real**. Chromium reescribe `Local State` desde memoria al cerrar y desharía todos los cambios. La función comprueba `pgrep -x chromium` y aborta con exit 2 si detecta procesos vivos. En `--dry-run` este check no se activa.
- **Operación destructiva e irreversible**: todos los datos del perfil (cookies, logins guardados, historial, caché, contraseñas) se pierden permanentemente al borrar la carpeta. No hay papelera.
- **Backup automático de Local State**: antes de editar, la función crea `<udd>/Local State.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. Restaurar manualmente: `cp "Local State.bak.YYYYMMDD" "Local State"`.
- **Validación JSON tras edición**: si el JSON de Local State queda inválido (raro pero posible con perfiles con nombres muy especiales), la función restaura el backup automáticamente y sale con exit != 0.
- **Nombres de perfil con espacios**: los nombres como `"Profile 1"` se pasan entre comillas al script Python. El parsing usa `json.loads` por lo que los espacios no dan problemas, pero deben pasarse correctamente en el shell: `--profile "Profile 1"`.
- **python3 > jq > warning**: usa python3 para editar Local State, jq como fallback. Si ninguno está disponible, las carpetas se borran pero Local State queda sin modificar (Chromium podría mostrar perfiles fantasma al arrancar).
- **last_used reasignado automáticamente**: si el perfil borrado era el `last_used`, la función asigna el primer perfil restante en `info_cache`. Si no queda ningún perfil, `last_used` queda como cadena vacía.
- **No afecta a `--profile Default` si es el único perfil**: lo borrará igualmente — Chromium puede quedar sin ningún perfil configurado y recreará Default al arrancar.
## Exit codes
| Código | Significado |
|--------|-------------|
| 0 | Éxito o dry-run completado |
| 1 | Argumento inválido, directorio o Local State no encontrado, JSON inválido tras edición |
| 2 | Chromium está corriendo (solo en modo real) |
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# delete_chrome_profile — borra por completo uno o varios perfiles Chrome/Chromium:
# elimina la carpeta del perfil y limpia todas las referencias en Local State
# (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups).
set -euo pipefail
delete_chrome_profile() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir=""
local _profiles=()
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]
--user-data-dir <dir> Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile <name> Nombre de la carpeta del perfil, ej. "Default" o "Profile 1"
(repetible, al menos uno obligatorio).
--dry-run Muestra qué borraría y qué claves de Local State quitaría
sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
2 chromium está corriendo (solo en modo real)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "delete_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones de argumentos ────────────────────────────────────────────
if [[ -z "$_user_data_dir" ]]; then
echo "delete_chrome_profile: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
echo "delete_chrome_profile: se requiere al menos un --profile" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "delete_chrome_profile: user-data-dir no encontrado: ${_user_data_dir}" >&2
return 1
fi
local _local_state="${_user_data_dir}/Local State"
if [[ ! -f "$_local_state" ]]; then
echo "delete_chrome_profile: Local State no encontrado: ${_local_state}" >&2
return 1
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
# para NO auto-matchear el propio `grep`/`pgrep` del pipe: como el path del udd contiene la
# cadena "chromium" (p.ej. ~/.config/chromium-cdp), un `pgrep -af '[c]hromium' | grep <udd>`
# se detecta a sí mismo. pgrep -x chromium solo lista procesos cuyo nombre es exactamente
# "chromium" (el navegador), nunca grep/pgrep/bash.
if [[ $_dry_run -eq 0 ]]; then
local _p _busy=0
for _p in $(pgrep -x chromium 2>/dev/null); do
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_user_data_dir"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "delete_chrome_profile: hay un chromium con este user-data-dir abierto — ciérralo antes de borrar perfiles:" >&2
echo " pkill -TERM chromium" >&2
echo "(Chromium reescribe Local State desde memoria al cerrar y desharía el borrado)" >&2
return 2
fi
fi
local _today
_today="$(date +%Y%m%d)"
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== delete_chrome_profile DRY-RUN ===" >&2
local _p
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
if [[ -d "$_pdir" ]]; then
echo " [borraría] rm -rf ${_pdir}" >&2
else
echo " [no existe] ${_pdir}" >&2
fi
echo " [Local State] quitaría claves para perfil: '${_p}'" >&2
echo " profile.info_cache.${_p}" >&2
echo " profile.profiles_order (entrada '${_p}')" >&2
echo " profile.last_active_profiles (entrada '${_p}')" >&2
echo " profile.last_used (si == '${_p}', reasignar)" >&2
echo " variations_google_groups.${_p} (si existe)" >&2
done
# Construir JSON de dry-run inline
local _dry_items="" _dry_first=1
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
local _sep="" _exists="false"
[[ $_dry_first -eq 0 ]] && _sep=","
_dry_first=0
[[ -d "$_pdir" ]] && _exists="true"
_dry_items+="${_sep}{\"profile\":\"${_p}\",\"dir_exists\":${_exists},\"would_remove\":${_exists},\"local_state_would_clean\":true}"
done
printf '{"dry_run":true,"would_delete":[%s]}\n' "$_dry_items"
return 0
fi
# ── backup de Local State (no sobreescribir el del día) ───────────────────
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# ── borrar carpetas de perfil ──────────────────────────────────────────────
local _deleted_results=() # "profile|dir_removed|ls_cleaned"
local _p
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
local _dir_removed=false
if [[ -d "$_pdir" ]]; then
rm -rf "$_pdir"
_dir_removed=true
fi
_deleted_results+=("${_p}|${_dir_removed}|false")
done
# ── construir lista Python de perfiles a eliminar ─────────────────────────
local _py_profiles_list=""
for _p in "${_profiles[@]}"; do
_py_profiles_list+="\"${_p}\","
done
_py_profiles_list="[${_py_profiles_list%,}]"
# ── editar Local State con python3 ────────────────────────────────────────
local _ls_cleaned=false
if command -v python3 >/dev/null 2>&1; then
python3 - "$_local_state" "$_py_profiles_list" <<'PY'
import sys, json
ls_path = sys.argv[1]
profiles_to_delete = json.loads(sys.argv[2])
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
profile_section = data.get("profile", {})
# 1. profile.info_cache — eliminar cada perfil
info_cache = profile_section.get("info_cache", {})
for p in profiles_to_delete:
info_cache.pop(p, None)
# 2. profile.profiles_order — quitar entradas del perfil
if "profiles_order" in profile_section and isinstance(profile_section["profiles_order"], list):
profile_section["profiles_order"] = [
x for x in profile_section["profiles_order"] if x not in profiles_to_delete
]
# 3. profile.last_active_profiles — quitar entradas del perfil
if "last_active_profiles" in profile_section and isinstance(profile_section["last_active_profiles"], list):
profile_section["last_active_profiles"] = [
x for x in profile_section["last_active_profiles"] if x not in profiles_to_delete
]
# 4. profile.last_used — reasignar si apunta a un perfil borrado
last_used = profile_section.get("last_used", "")
if last_used in profiles_to_delete:
remaining = [k for k in info_cache.keys() if k not in profiles_to_delete]
profile_section["last_used"] = remaining[0] if remaining else ""
# 5. variations_google_groups — limpiar entradas del perfil (si existe)
vgg = data.get("variations_google_groups", {})
for p in profiles_to_delete:
vgg.pop(p, None)
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
_ls_cleaned=true
# ── fallback con jq ───────────────────────────────────────────────────────
elif command -v jq >/dev/null 2>&1; then
local _tmp_ls
_tmp_ls="$(mktemp)"
local _jq_expr="."
for _p in "${_profiles[@]}"; do
_jq_expr+=" | del(.profile.info_cache[\"${_p}\"])"
_jq_expr+=" | del(.variations_google_groups[\"${_p}\"])"
_jq_expr+=" | if .profile.profiles_order then .profile.profiles_order -= [\"${_p}\"] else . end"
_jq_expr+=" | if .profile.last_active_profiles then .profile.last_active_profiles -= [\"${_p}\"] else . end"
done
if jq "${_jq_expr}" "$_local_state" > "$_tmp_ls" 2>/dev/null; then
mv "$_tmp_ls" "$_local_state"
_ls_cleaned=true
else
echo "delete_chrome_profile: advertencia — jq falló editando Local State" >&2
rm -f "$_tmp_ls"
fi
else
echo "delete_chrome_profile: advertencia — ni python3 ni jq disponibles; carpetas borradas pero Local State no modificado" >&2
fi
# ── validar que el JSON resultante sigue siendo parseable ─────────────────
if [[ "$_ls_cleaned" == "true" ]]; then
if command -v python3 >/dev/null 2>&1; then
if ! python3 -c "import sys, json; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "delete_chrome_profile: JSON de Local State inválido tras edición — restaurando backup" >&2
cp "$_backup" "$_local_state"
return 1
fi
fi
fi
# ── actualizar _deleted_results con ls_cleaned ────────────────────────────
local _updated_results=()
for _entry in "${_deleted_results[@]}"; do
local _ep _edr _els
IFS='|' read -r _ep _edr _els <<< "$_entry"
_updated_results+=("${_ep}|${_edr}|${_ls_cleaned}")
done
# ── leer last_used resultante ──────────────────────────────────────────────
local _new_last_used=""
if command -v python3 >/dev/null 2>&1; then
_new_last_used="$(python3 -c "
import sys, json
data = json.load(open(sys.argv[1]))
print(data.get('profile', {}).get('last_used', ''))
" "$_local_state" 2>/dev/null || echo "")"
fi
# ── construir JSON de resultado inline ────────────────────────────────────
local _result_items="" _res_first=1
for _entry in "${_updated_results[@]+"${_updated_results[@]}"}"; do
local _pn _dr _lc
IFS='|' read -r _pn _dr _lc <<< "$_entry"
local _rsep=""
[[ $_res_first -eq 0 ]] && _rsep=","
_res_first=0
_result_items+="${_rsep}{\"profile\":\"${_pn}\",\"dir_removed\":${_dr},\"local_state_cleaned\":${_lc}}"
done
printf '{"deleted":[%s],"last_used":"%s","backup":"Local State.bak.%s"}\n' \
"$_result_items" "$_new_last_used" "$_today"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then
delete_chrome_profile "$@"
fi
@@ -0,0 +1,74 @@
---
name: prepare_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> [--keep <ext_id>]... [--force]"
description: "Clona un user-data-dir de Chrome/Chromium creando un perfil de scraping limpio: conserva solo las extensiones de una lista blanca (por defecto uBlock Origin Lite) y excluye caché, locks y sesiones antiguas."
tags: [chrome, browser, profile, scraping, extensions, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/prepare_chrome_profile.sh"
params:
- name: --src
desc: "user-data-dir origen con un perfil Chrome/Chromium ya configurado (debe existir --src/Default)"
- name: --dst
desc: "Ruta de destino del nuevo perfil; no debe existir salvo que se pase --force"
- name: --keep
desc: "ID de extensión Chrome a conservar (repetible). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
- name: --force
desc: "Borra --dst si existe antes de recrearlo. Sin este flag la función aborta si --dst ya existe"
output: "JSON en stdout: {dst, kept: [id...], removed: [id...]}. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/prepare_chrome_profile.sh
prepare_chrome_profile \
--src "$HOME/.config/chromium" \
--dst "$HOME/.local/share/web_scraping/chrome-profile"
# Con extensión adicional conservada
prepare_chrome_profile \
--src "$HOME/.config/chromium" \
--dst "$HOME/.local/share/web_scraping/chrome-profile" \
--keep "ddkjiahejlhfcafbddmgiahcphecmpfh" \
--keep "cjpalhdlnbpafiamejdnhcphjbkeiagm" \
--force
# Salida esperada (ejemplo):
# {"dst":"/home/enmanuel/.local/share/web_scraping/chrome-profile","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["abcdefghijklmnopabcdefghijklmnop","dark-reader-id"]}
```
## Cuando usarla
Úsala antes de lanzar una sesión de scraping/automatización para partir de un perfil aislado: con uBlock Origin Lite activo (menos anuncios/trackers = DOM más limpio, respuestas más rápidas) pero sin extensiones que interfieren (Dark Reader muta colores del DOM, NoScript bloquea JS, OneTab modifica tabs). También sirve para aislar sesiones de diferentes proyectos de scraping sin contaminar el perfil personal.
## Gotchas
- **Chrome debe estar CERRADO sobre `--src`** antes de ejecutar. Los archivos SQLite (`Cookies`, `History`, `Login Data`, etc.) estarán bloqueados si Chrome está abierto, y `rsync` copiará versiones inconsistentes. Verificar con `pgrep -x chromium` o `pgrep -x chrome`.
- **HMAC de Secure Preferences**: el archivo `Local State` contiene la semilla HMAC que Chrome usa para verificar `Preferences` y `Secure Preferences`. Si no se copia (o se copia entre máquinas distintas con distinto binding), Chrome puede invalidar las extensiones al arrancar y resetear configuraciones. La función copia `Local State` automáticamente, pero la copia entre máquinas puede seguir produciendo resets de extensiones — esto es comportamiento esperado de Chrome, no un bug de esta función.
- **Purga de referencias en Preferences**: tras borrar las carpetas de extensiones fuera de la whitelist, la función también elimina con `python3` las entradas `extensions.settings.<id>` de `Default/Preferences` y `Default/Secure Preferences`, los IDs de `extensions.pinned_extensions` y las claves `protection.macs.extensions.settings.<id>`. Sin esta limpieza Chrome detecta las entradas en Preferences (con `from_webstore`/install_source) y **vuelve a descargar la extensión del Web Store al arrancar**, deshaciendo el filtrado (caso real: Dark Reader reaparece y oscurece páginas rompiendo screenshots). Si `python3` falla al procesar un Preferences concreto se emite un warning a stderr pero la función no aborta — el borrado de carpetas ya es el efecto principal.
- **`--force` borra `--dst` completamente**: si `--dst` es un perfil con datos que quieres conservar, no uses `--force` sin antes hacer backup.
- **Extensiones instaladas desde Web Store vs unpacked**: esta función opera sobre la carpeta `Extensions/` física. Las extensiones instaladas desde la Web Store tienen IDs de 32 caracteres en minúsculas. Las extensiones unpacked (`--load-extension`) no viven en `Extensions/` y no se ven afectadas.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento inválido o `--src/Default` no existe |
| 2 | `--dst` ya existe y no se pasó `--force` |
| 3 | `--src` y `--dst` resuelven al mismo path real |
| 4 | Error durante `rsync` |
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# prepare_chrome_profile — clona un user-data-dir de Chrome/Chromium conservando solo
# las extensiones de una lista blanca. Sirve para perfiles de scraping limpios.
set -euo pipefail
# ── defaults ──────────────────────────────────────────────────────────────────
_SRC=""
_DST=""
_FORCE=0
# uBlock Origin Lite por defecto
_KEEP=()
_DEFAULT_EXT="ddkjiahejlhfcafbddmgiahcphecmpfh"
# ── parse args ────────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> \
[--keep <ext_id>]... [--force]
--src user-data-dir origen (ej. $HOME/.config/chromium)
--dst user-data-dir destino a crear
--keep ID de extensión a conservar (repetible). Default: uBlock Origin Lite
--force si --dst existe, lo borra y recrea; sin flag aborta si existe
Exit codes:
0 éxito
1 error de argumento o validación
2 --dst ya existe y no se pasó --force
3 --src igual a --dst (mismo path real)
4 error de copia/rsync
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--src) _SRC="$2"; shift 2 ;;
--dst) _DST="$2"; shift 2 ;;
--keep) _KEEP+=("$2"); shift 2 ;;
--force) _FORCE=1; shift ;;
-h|--help) _usage ;;
*) echo "prepare_chrome_profile: argumento desconocido: $1" >&2; _usage ;;
esac
done
# ── validaciones básicas ──────────────────────────────────────────────────────
if [[ -z "$_SRC" || -z "$_DST" ]]; then
echo "prepare_chrome_profile: --src y --dst son obligatorios" >&2
exit 1
fi
if [[ ! -d "$_SRC/Default" ]]; then
echo "prepare_chrome_profile: $_SRC/Default no existe; ¿es un user-data-dir válido?" >&2
exit 1
fi
# Resolver paths reales para comparar (evitar borrar src cuando src==dst)
_SRC_REAL="$(realpath "$_SRC")"
_DST_REAL="$(realpath -m "$_DST")" # -m: no requiere que exista
if [[ "$_SRC_REAL" == "$_DST_REAL" ]]; then
echo "prepare_chrome_profile: --src y --dst resuelven al mismo path: $_SRC_REAL" >&2
exit 3
fi
# También rechazar si --dst es prefijo de --src (evitar borrar el origen)
if [[ "$_SRC_REAL" == "$_DST_REAL"/* ]]; then
echo "prepare_chrome_profile: --src está dentro de --dst; operación peligrosa, abortando" >&2
exit 3
fi
# ── lista blanca de extensiones ───────────────────────────────────────────────
if [[ ${#_KEEP[@]} -eq 0 ]]; then
_KEEP=("$_DEFAULT_EXT")
fi
# ── gestionar destino ─────────────────────────────────────────────────────────
if [[ -d "$_DST" ]]; then
if [[ $_FORCE -eq 1 ]]; then
rm -rf "$_DST"
else
echo "prepare_chrome_profile: $_DST ya existe; usa --force para sobreescribir" >&2
exit 2
fi
fi
mkdir -p "$_DST/Default"
# ── copiar Local State (HMAC seed para Secure Preferences) ────────────────────
if [[ -f "$_SRC/Local State" ]]; then
cp "$_SRC/Local State" "$_DST/Local State"
fi
# ── rsync del perfil Default excluyendo caché y locks ─────────────────────────
rsync -a \
--exclude='Cache/' \
--exclude='Code Cache/' \
--exclude='GPUCache/' \
--exclude='Dawn Cache/' \
--exclude='DawnGraphiteCache/' \
--exclude='DawnWebGPUCache/' \
--exclude='Service Worker/CacheStorage/' \
--exclude='Service Worker/ScriptCache/' \
--exclude='Singleton*' \
--exclude='*.lock' \
--exclude='lockfile' \
--exclude='Sessions/' \
--exclude='Session Storage/' \
--exclude='Current Session' \
--exclude='Current Tabs' \
--exclude='Last Session' \
--exclude='Last Tabs' \
"$_SRC/Default/" "$_DST/Default/" || {
echo "prepare_chrome_profile: rsync falló (exit $?)" >&2
exit 4
}
# ── eliminar extensiones fuera de la lista blanca ────────────────────────────
_EXT_DIR="$_DST/Default/Extensions"
_removed=()
_kept=()
if [[ -d "$_EXT_DIR" ]]; then
while IFS= read -r -d '' ext_path; do
ext_id="$(basename "$ext_path")"
# Conservar siempre la carpeta Temp (usada por Chrome durante installs)
if [[ "$ext_id" == "Temp" ]]; then
continue
fi
# Comprobar si está en la lista blanca
_in_keep=0
for keep_id in "${_KEEP[@]}"; do
if [[ "$ext_id" == "$keep_id" ]]; then
_in_keep=1
break
fi
done
if [[ $_in_keep -eq 1 ]]; then
_kept+=("$ext_id")
else
rm -rf "$ext_path"
_removed+=("$ext_id")
fi
done < <(find "$_EXT_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
fi
# ── purgar referencias a extensiones eliminadas en Preferences ───────────────
# Chrome re-descarga del Web Store cualquier extensión que aparezca en
# extensions.settings aunque su carpeta haya sido borrada. Editamos el JSON
# con python3 para evitar ese comportamiento.
if [[ ${#_removed[@]} -gt 0 ]]; then
# Construir lista Python de IDs eliminados
_py_ids_list=""
for _id in "${_removed[@]}"; do
_py_ids_list+="\"${_id}\","
done
_py_ids_list="[${_py_ids_list%,}]"
for _prefs_file in "$_DST/Default/Preferences" "$_DST/Default/Secure Preferences"; do
if [[ -f "$_prefs_file" ]]; then
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
echo "prepare_chrome_profile: advertencia — no se pudieron purgar refs en $(basename "$_prefs_file")" >&2
import sys, json
prefs_path = sys.argv[1]
removed_ids = json.loads(sys.argv[2])
with open(prefs_path, "r", encoding="utf-8") as f:
data = json.load(f)
# 1. extensions.settings.<id>
ext_settings = data.get("extensions", {}).get("settings", {})
for ext_id in removed_ids:
ext_settings.pop(ext_id, None)
# 2. extensions.pinned_extensions (lista de IDs)
pinned = data.get("extensions", {}).get("pinned_extensions", None)
if isinstance(pinned, list):
data["extensions"]["pinned_extensions"] = [
pid for pid in pinned if pid not in removed_ids
]
# 3. protection.macs.extensions.settings.<id> (Secure Preferences)
try:
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
for ext_id in removed_ids:
mac_ext.pop(ext_id, None)
except (KeyError, TypeError):
pass
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
fi
done
fi
# ── emitir resultado JSON ─────────────────────────────────────────────────────
_json_array() {
# Convierte array bash en JSON array de strings
local arr=("$@")
local out="["
local first=1
for item in "${arr[@]}"; do
if [[ $first -eq 1 ]]; then
out+="\"$item\""
first=0
else
out+=",\"$item\""
fi
done
out+="]"
echo "$out"
}
_kept_json="$(_json_array "${_kept[@]+"${_kept[@]}"}")"
_removed_json="$(_json_array "${_removed[@]+"${_removed[@]}"}")"
printf '{"dst":"%s","kept":%s,"removed":%s}\n' \
"$_DST_REAL" \
"$_kept_json" \
"$_removed_json"
@@ -0,0 +1,93 @@
---
name: restore_chrome_bookmarks
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "restore_chrome_bookmarks --backup-dir <ts-dir> [--user-data-dir <dir>] [--profile <name>]... [--dry-run]"
description: "Restaura archivos Bookmarks de Chrome/Chromium desde un directorio de backup generado por backup_chrome_bookmarks hacia los perfiles destino en user-data-dir. Copia byte a byte con cp -p para preservar el checksum MD5 interno del archivo. Nunca parsea ni reserializa el JSON. Requiere que Chromium esté cerrado antes de ejecutar."
tags: [navegator, chromium, bookmarks, restore, browser, scraping, profile]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/restore_chrome_bookmarks.sh"
params:
- name: --backup-dir
desc: "Directorio de backup con timestamp generado por backup_chrome_bookmarks. Debe contener subdirectorios <profile>/Bookmarks. OBLIGATORIO."
- name: --user-data-dir
desc: "Ruta raíz del user-data-dir de Chrome/Chromium destino. Default: ~/.config/chromium"
- name: --profile
desc: "Nombre del perfil a restaurar (repetible, ej. Default, Profile 1). Si no se pasa ninguno se restauran TODOS los perfiles presentes en el backup-dir."
- name: --dry-run
desc: "Muestra qué archivos se copiarían y cuáles Bookmarks.bak se borrarían, sin tocar nada en disco."
output: "JSON en stdout: {\"restored\": [{\"profile\": \"Default\", \"dst\": \"<path>\", \"bytes\": N}, ...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# PASO 1 — cerrar Chromium (OBLIGATORIO en modo real)
pkill -TERM chromium
# PASO 2 — restaurar todos los perfiles desde el backup más reciente
source $HOME/fn_registry/bash/functions/browser/restore_chrome_bookmarks.sh
restore_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium" \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00"
# Restaurar solo un perfil concreto
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--profile Default
# Restaurar dos perfiles específicos
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--profile Default \
--profile "Profile 1"
# Previsualizar sin tocar nada (no necesita Chromium cerrado)
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--dry-run
# Salida esperada:
# {"restored":[{"profile":"Default","dst":"/home/enmanuel/.config/chromium/Default/Bookmarks","bytes":12453}]}
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run restore_chrome_bookmarks_bash_browser -- \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--dry-run
```
## Cuando usarla
Úsala después de una sesión de scraping o automatización que haya alterado los bookmarks, o para recuperar bookmarks tras formatear/recrear un perfil de Chromium. Combínala con `backup_chrome_bookmarks` (que genera el `--backup-dir` con la estructura esperada) para tener un ciclo completo de backup/restore. También útil para propagar bookmarks de un perfil o PC a otro.
## Gotchas
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium mantiene los bookmarks en memoria y los reescribe al archivo `Bookmarks` al cerrar; si restauras con Chromium abierto, el proceso sobreescribirá tu restauración al cerrarse. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` este check se omite.
- **Copia verbatim — nunca reserializar el JSON**. El archivo `Bookmarks` contiene un campo `checksum` con el MD5 del propio contenido JSON (calculado por Chromium internamente). Si se parsea y reserializa el JSON (aunque sea equivalente), el checksum queda inválido y Chromium descarta silenciosamente el archivo y regenera uno vacío. Esta función usa `cp -p` para garantizar que los bytes son idénticos al original.
- **En Chromium 148 los bookmarks NO están bajo `super_mac` de Secure Preferences**. No es necesario tocar `Preferences` ni `Secure Preferences` al restaurar bookmarks (a diferencia de extensiones). La función solo opera sobre el archivo `Bookmarks`.
- **`Bookmarks.bak` residual se borra**. Chromium crea `Bookmarks.bak` como copia de seguridad interna. Si existe antes de la restauración, esta función lo borra para que Chromium no lo use como fallback en lugar del archivo recién restaurado.
- **El directorio destino del perfil se crea si no existe**. Si el perfil aún no tiene directorio en `user-data-dir`, se crea con `mkdir -p`. Chromium lo inicializará correctamente la primera vez que arranque con ese perfil.
- **Opera por perfil**. Si no pasas `--profile`, restaura todos los perfiles presentes en el backup. Pasa `--profile` explícito para restaurar selectivamente y evitar sobreescribir perfiles sin querer.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito o dry-run completado |
| 1 | Argumento inválido, backup-dir/user-data-dir no encontrado, o perfil no presente en backup |
| 2 | Chromium está corriendo (solo en modo real) |
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# restore_chrome_bookmarks — restaura archivos Bookmarks de un backup generado por
# backup_chrome_bookmarks hacia los perfiles destino en user-data-dir.
# Copia byte a byte con cp -p (nunca parsea ni reserializa el JSON).
set -euo pipefail
restore_chrome_bookmarks() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir="${HOME}/.config/chromium"
local _backup_dir=""
local _profiles=()
local _dry_run=0
# ── parse args ────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: restore_chrome_bookmarks --backup-dir <ts-dir>
[--user-data-dir <dir>] [--profile <name>]... [--dry-run]
--user-data-dir Raíz de perfiles destino. Default: ~/.config/chromium
--backup-dir Directorio de backup con timestamp generado por
backup_chrome_bookmarks. Debe contener subdirectorios
<profile>/Bookmarks. OBLIGATORIO.
--profile <name> Perfil a restaurar (repetible). Si no se pasa ninguno
se restauran TODOS los perfiles presentes en backup-dir.
--dry-run Muestra qué se copiaría sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
2 chromium está corriendo (solo en modo real)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "restore_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones ──────────────────────────────────────────────────────────
if [[ -z "$_backup_dir" ]]; then
echo "restore_chrome_bookmarks: --backup-dir es obligatorio" >&2
return 1
fi
if [[ ! -d "$_backup_dir" ]]; then
echo "restore_chrome_bookmarks: backup-dir no encontrado: ${_backup_dir}" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "restore_chrome_bookmarks: user-data-dir no encontrado: ${_user_data_dir}" >&2
return 1
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
# para NO auto-matchear el propio `grep`/`pgrep`: el path del udd contiene "chromium"
# (~/.config/chromium-cdp), así que un `pgrep -af '[c]hromium' | grep <udd>` se detecta a sí mismo.
if [[ $_dry_run -eq 0 ]]; then
local _p _busy=0
for _p in $(pgrep -x chromium 2>/dev/null); do
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_user_data_dir"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "restore_chrome_bookmarks: hay un chromium con este user-data-dir abierto — ciérralo antes de restaurar:" >&2
echo " pkill -TERM chromium" >&2
echo "(Chromium reescribe Bookmarks desde memoria al cerrar y desharía la restauración)" >&2
return 2
fi
fi
# ── determinar perfiles a restaurar ───────────────────────────────────────
local _target_profiles=()
if [[ ${#_profiles[@]} -gt 0 ]]; then
# Perfiles explícitos: verificar que existen en el backup
local _p
for _p in "${_profiles[@]}"; do
if [[ ! -f "${_backup_dir}/${_p}/Bookmarks" ]]; then
echo "restore_chrome_bookmarks: backup no contiene perfil '${_p}': ${_backup_dir}/${_p}/Bookmarks" >&2
return 1
fi
_target_profiles+=("$_p")
done
else
# Autodescubrir todos los perfiles en el backup
local _profile_path
while IFS= read -r -d '' _profile_path; do
local _pname
_pname="$(basename "$(dirname "$_profile_path")")"
_target_profiles+=("$_pname")
done < <(find "$_backup_dir" -mindepth 2 -maxdepth 2 -name "Bookmarks" -print0 | sort -z)
if [[ ${#_target_profiles[@]} -eq 0 ]]; then
echo "restore_chrome_bookmarks: no se encontraron archivos Bookmarks en: ${_backup_dir}" >&2
return 1
fi
fi
# ── restaurar cada perfil ─────────────────────────────────────────────────
local _restored_json=""
local _first=1
local _prof
for _prof in "${_target_profiles[@]}"; do
local _src="${_backup_dir}/${_prof}/Bookmarks"
local _dst_dir="${_user_data_dir}/${_prof}"
local _dst="${_dst_dir}/Bookmarks"
local _dst_bak="${_dst_dir}/Bookmarks.bak"
# Tamaño del archivo fuente para el JSON de salida
local _bytes=0
if [[ -f "$_src" ]]; then
_bytes="$(wc -c < "$_src")"
# Eliminar espacios que wc puede añadir en algunas plataformas
_bytes="${_bytes// /}"
fi
if [[ $_dry_run -eq 1 ]]; then
echo "=== restore_chrome_bookmarks DRY-RUN ===" >&2
echo " Perfil : ${_prof}" >&2
echo " src : ${_src}" >&2
echo " dst : ${_dst}" >&2
echo " bytes : ${_bytes}" >&2
if [[ -f "$_dst_bak" ]]; then
echo " .bak : borraría ${_dst_bak}" >&2
fi
else
# Crear directorio destino si no existe
mkdir -p "$_dst_dir"
# Copiar byte a byte preservando timestamps (NUNCA reserializar)
cp -p "$_src" "$_dst"
# Borrar Bookmarks.bak residual si existe
if [[ -f "$_dst_bak" ]]; then
rm -f "$_dst_bak"
fi
fi
# Construir fragmento JSON para este perfil
local _entry
_entry="$(printf '{"profile":"%s","dst":"%s","bytes":%s}' \
"$_prof" "$_dst" "$_bytes")"
if [[ $_first -eq 1 ]]; then
_restored_json="${_entry}"
_first=0
else
_restored_json+=",$_entry"
fi
done
# ── emitir resultado JSON ─────────────────────────────────────────────────
printf '{"restored":[%s]}\n' "$_restored_json"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
restore_chrome_bookmarks "$@"
fi
@@ -0,0 +1,100 @@
---
name: set_chrome_profile_appearance
kind: function
lang: bash
domain: browser
version: "1.1.0"
purity: impure
signature: "set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name> [--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]"
description: "Personaliza la apariencia visual de un perfil Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen PNG/JPG custom, y/o un color de acento (hex #rrggbb). Con --color aplica el tinte tanto al círculo del avatar en Local State (profile_highlight_color, profile_color_seed, default_avatar_fill_color) como al tema completo del navegador en el Preferences del perfil (browser.theme.user_color2, browser_color_variant, extensions.theme.system_theme), tiñendo toolbar, frame, barra de pestañas y omnibox. Requiere que Chromium esté cerrado sobre el user-data-dir. Hace backup de Local State y Preferences antes de escribir y valida el JSON resultante."
tags: [navegator, chromium, profile, browser, cdp, scraping, appearance, avatar, color]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/set_chrome_profile_appearance.sh"
params:
- name: --user-data-dir
desc: "Raíz del user-data-dir de Chrome/Chromium donde vive el perfil. El directorio y Local State deben existir. Obligatorio."
- name: --profile
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, Automation, \"Profile 1\". El perfil debe existir previamente en info_cache de Local State. Obligatorio."
- name: --avatar
desc: "Índice entero 0..55 del avatar built-in de Chrome (56 avatares: animales, objetos, personas) o ruta absoluta/relativa a un archivo PNG/JPG para avatar custom. Con índice: sets avatar_icon=IDR_PROFILE_AVATAR_<N> e is_using_default_avatar=true. Con imagen: copia el archivo al perfil como 'Google Profile Picture.png' y sets is_using_default_avatar=false. Opcional; al menos uno de --avatar o --color debe darse."
- name: --color
desc: "Color de acento del perfil en hex #rrggbb, con o sin el '#' inicial. Se convierte a int32 con signo en formato ARGB 0xFFRRGGBB. Aplica el color en dos lugares: (1) Local State info_cache (profile_highlight_color, profile_color_seed, default_avatar_fill_color) para el círculo del avatar; (2) Preferences del perfil (browser.theme.user_color2 + browser_color_variant + extensions.theme.system_theme=0) para teñir toolbar, frame, barra de pestañas y omnibox. Opcional; al menos uno de --avatar o --color debe darse."
- name: --variant
desc: "Intensidad del tema de color aplicado al navegador (browser_color_variant). Entero 0..4: 0=system, 1=tonal_spot, 2=neutral, 3=vibrant (default), 4=expressive. Valores más altos dan tintes más saturados e identificables. Solo tiene efecto cuando se usa --color. Opcional."
- name: --dry-run
desc: "Describe las acciones que se ejecutarían (campos a modificar en Local State y Preferences, conversión de color, ruta del Preferences) sin escribir nada ni verificar si Chromium está corriendo. Emite JSON de resultado con dry_run:true."
output: "JSON en stdout con los campos resultantes del perfil: {\"profile\":\"<dir>\",\"avatar_icon\":\"...\",\"is_using_default_avatar\":true|false,\"profile_highlight_color\":<int>,\"profile_color_seed\":<int>,\"default_avatar_fill_color\":<int>,\"theme_applied\":true|false,\"variant\":<int>,\"preferences_path\":\"...\",\"browser_theme_user_color2\":<int>,\"browser_theme_color_variant\":<int>,\"extensions_theme_system_theme\":<int>,\"backup\":\"Local State.bak.YYYYMMDD\"}. En dry-run: {\"profile\":\"...\",\"avatar_applied\":true|false,\"color_applied\":true|false,\"theme_applied\":true|false,\"variant\":<int>,\"dry_run\":true}. Mensajes de diagnóstico a stderr. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/set_chrome_profile_appearance.sh
# Asignar avatar #30 y tinte verde a toolbar/frame/omnibox del perfil Automation
# (verde #16a34a tiñe toda la chrome del navegador, no solo el círculo del avatar)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Automation \
--avatar 30 \
--color "#16a34a"
# Salida JSON incluye: theme_applied:true, variant:3, browser_theme_user_color2:-15293622
# Color con intensidad personalizada (expressive = máxima saturación)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Scraping \
--color "#1f6feb" \
--variant 4
# Solo cambiar avatar (no toca Preferences del perfil)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile "Profile 1" \
--avatar 5
# Dry-run: ver qué se aplicaría en Local State y Preferences sin escribir
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Automation \
--avatar 30 \
--color "#16a34a" \
--dry-run
```
## Cuando usarla
Úsala para diferenciar visualmente los perfiles de un user-data-dir de automatización — un color y avatar distintos por perfil hacen inmediata la identificación en el selector de Chrome Y en la chrome del navegador (toolbar/frame visible mientras navega). Ejecútala justo después de `create_chrome_profile` (con `--no-launch`) o como paso independiente de personalización batch antes de lanzar sesiones CDP. Si solo quieres teñir el círculo del avatar (sin el tema), basta esta función; si quieres el tinte completo del navegador (lo más identificable), pasa `--color`.
## Gotchas
- **Chromium debe estar cerrado**: Chrome reescribe `Local State` y `Preferences` completos desde memoria al cerrar; si se ejecuta mientras hay un proceso chromium vivo sobre el mismo user-data-dir, Chrome sobreescribirá los cambios al salir. La función detecta esto con `pgrep -x chromium` filtrando por cmdline y sale con exit 2 antes de modificar nada. Usa `pkill -TERM chromium` para cerrar y espera unos segundos.
- **El tema se escribe en Preferences del perfil, distinto de Local State**: los cambios de color al avatar van en `<user-data-dir>/Local State` (global a todos los perfiles); los cambios de tema del navegador van en `<user-data-dir>/<profile_dir>/Preferences` (específico de cada perfil). La función hace backup de ambos archivos por separado antes de tocarlos.
- **El perfil debe existir en info_cache**: esta función personaliza perfiles existentes; no los crea. Usa `create_chrome_profile` primero (con `--no-launch` basta para que aparezca en Local State) y luego `set_chrome_profile_appearance`.
- **color es int32 con signo en ARGB**: Chrome almacena el color como entero con signo de 32 bits en formato `0xAARRGGBB`. Un color como `#16a34a` (verde) da ARGB `0xFF16A34A` → signed int32 `-15293622`. La función hace la conversión internamente; tú pasas siempre hex `#rrggbb`.
- **En modo oscuro del sistema el tinte sale más apagado**: en temas oscuros del sistema el color se mezcla con el fondo oscuro y queda menos saturado. Para compensar, usa `--variant 3` (vibrant, default) o `--variant 4` (expressive); valores bajos como 1 o 2 pueden resultar casi imperceptibles en modo oscuro.
- **`extensions.theme.system_theme` se fuerza a 0**: si el perfil usaba el tema GTK del sistema (`system_theme=1`), el GTK puede ignorar el `user_color`. Esta función lo fuerza a 0 (tema propio de Chrome) para que el `user_color2` tenga efecto. Si quieres devolver el perfil al tema del sistema, tendrás que resetear `system_theme` manualmente.
- **Avatar custom (imagen) es best-effort**: el campo `gaia_picture_file_name` y `is_using_default_avatar=false` se aplican correctamente en Local State y la imagen se copia al directorio del perfil. Sin embargo, Chrome puede ignorar la foto de perfil en perfiles sin sesión Google activa (Chromium sin cuenta). El camino robusto y garantizado es usar el índice built-in (`--avatar 0..55`): 56 avatares (animales, objetos, personas) son más que suficientes para diferenciar perfiles de automatización.
- **Backup diario**: se crea `Local State.bak.YYYYMMDD` y `Preferences.bak.YYYYMMDD` antes de cualquier escritura. Si ya existen los backups del día no se sobreescriben. Si el JSON resultante es inválido, se restaura automáticamente el backup correspondiente.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento obligatorio faltante, rango inválido o archivo de imagen no encontrado |
| 2 | Lock: hay un chromium usando el mismo user-data-dir |
| 3 | El perfil no existe en info_cache de Local State |
| 4 | Error editando Local State o Preferences (JSON inválido tras escritura, restaurado backup) |
## Capability growth log
v1.1.0 (2026-06-06) — --color ahora aplica también el tema del navegador (toolbar/frame/omnibox) escribiendo browser.theme.user_color2 + browser_color_variant en el Preferences del perfil, no solo el color del avatar en Local State. Nuevo flag --variant (0..4, default 3 vibrant). Verificado con captura en Chromium 148.
@@ -0,0 +1,426 @@
#!/usr/bin/env bash
# set_chrome_profile_appearance — personaliza la apariencia visual de un perfil
# Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen
# PNG/JPG custom, y/o un color de acento (hex #rrggbb). Edita Local State Y el
# Preferences del perfil (browser.theme.* para teñir toolbar/frame/omnibox).
set -euo pipefail
set_chrome_profile_appearance() {
# ── defaults ──────────────────────────────────────────────────────────────
local _udd=""
local _profile_dir=""
local _avatar=""
local _color=""
local _variant=3
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name>
[--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile Nombre de la carpeta del perfil, ej: Default, Automation,
"Profile 1" (obligatorio). El perfil debe existir.
--avatar Índice entero 0..55 del avatar built-in de Chrome, o ruta a
un archivo PNG/JPG para avatar custom (opcional).
--color Color de acento del perfil en formato hex #rrggbb, con o sin
el '#' inicial (opcional). Aplica el color tanto al círculo
del avatar (Local State) como al tema del navegador
(toolbar/frame/omnibox via Preferences del perfil).
--variant Intensidad del tema de color: 0=system, 1=tonal_spot,
2=neutral, 3=vibrant (default), 4=expressive. Solo tiene
efecto cuando se usa --color.
--dry-run Describe las acciones sin modificar nada.
Al menos uno de --avatar o --color debe indicarse.
Exit codes:
0 éxito
1 error de argumento o validación
2 lock: hay un chromium corriendo con este user-data-dir
3 el perfil no existe en info_cache de Local State
4 error editando Local State o Preferences (JSON inválido tras escritura)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _udd="$2"; shift 2 ;;
--profile) _profile_dir="$2"; shift 2 ;;
--avatar) _avatar="$2"; shift 2 ;;
--color) _color="$2"; shift 2 ;;
--variant) _variant="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "set_chrome_profile_appearance: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones obligatorias ──────────────────────────────────────────────
if [[ -z "$_udd" ]]; then
echo "set_chrome_profile_appearance: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ -z "$_profile_dir" ]]; then
echo "set_chrome_profile_appearance: --profile es obligatorio" >&2
return 1
fi
if [[ -z "$_avatar" && -z "$_color" ]]; then
echo "set_chrome_profile_appearance: al menos --avatar o --color debe indicarse" >&2
return 1
fi
# Validar --variant
if ! [[ "$_variant" =~ ^[0-4]$ ]]; then
echo "set_chrome_profile_appearance: --variant debe ser un entero 0..4, recibido: ${_variant}" >&2
return 1
fi
# Expandir ~ en el user-data-dir
_udd="${_udd/#\~/$HOME}"
local _local_state="${_udd}/Local State"
# Verificar que user-data-dir y Local State existen
if [[ ! -d "$_udd" ]]; then
echo "set_chrome_profile_appearance: user-data-dir no encontrado: ${_udd}" >&2
return 1
fi
if [[ ! -f "$_local_state" ]]; then
echo "set_chrome_profile_appearance: Local State no encontrado: ${_local_state}" >&2
return 1
fi
# ── validar --avatar ──────────────────────────────────────────────────────
local _avatar_index=-1
local _avatar_image_path=""
if [[ -n "$_avatar" ]]; then
if [[ "$_avatar" =~ ^[0-9]+$ ]]; then
# Índice built-in
_avatar_index=$(( _avatar ))
if [[ $_avatar_index -lt 0 || $_avatar_index -gt 55 ]]; then
echo "set_chrome_profile_appearance: índice de avatar fuera de rango (0..55): ${_avatar}" >&2
return 1
fi
else
# Ruta a imagen custom
local _img_path="${_avatar/#\~/$HOME}"
if [[ ! -f "$_img_path" ]]; then
echo "set_chrome_profile_appearance: archivo de imagen no encontrado: ${_img_path}" >&2
return 1
fi
_avatar_image_path="$_img_path"
fi
fi
# ── validar --color ───────────────────────────────────────────────────────
local _color_hex=""
if [[ -n "$_color" ]]; then
_color_hex="${_color/#\#/}" # quitar # inicial si lo hay
if ! [[ "$_color_hex" =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "set_chrome_profile_appearance: color hex inválido (espera rrggbb): ${_color}" >&2
return 1
fi
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto ──────────
# pgrep -x chromium lista solo procesos cuyo comm es exactamente "chromium",
# nunca grep/pgrep/bash. Así evitamos auto-matchear el propio script cuando
# el path del udd contiene "chromium" (p.ej. ~/.config/chromium-cdp).
if [[ $_dry_run -eq 0 ]]; then
local _p _busy=0
for _p in $(pgrep -x chromium 2>/dev/null); do
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "set_chrome_profile_appearance: hay un chromium corriendo con este user-data-dir — ciérralo primero:" >&2
echo " pkill -TERM chromium" >&2
echo " (Chrome reescribe Local State y Preferences al cerrar y pierde los cambios)" >&2
return 2
fi
fi
# ── verificar que el perfil existe en info_cache ──────────────────────────
if [[ $_dry_run -eq 0 ]]; then
local _profile_exists
_profile_exists="$(python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
ic = data.get('profile', {}).get('info_cache', {})
print('yes' if sys.argv[2] in ic else 'no')
" "$_local_state" "$_profile_dir" 2>/dev/null || echo "no")"
if [[ "$_profile_exists" != "yes" ]]; then
echo "set_chrome_profile_appearance: perfil '${_profile_dir}' no existe en info_cache de Local State" >&2
echo " Perfiles disponibles:" >&2
python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
ic = data.get('profile', {}).get('info_cache', {})
for k in ic: print(' ', k)
" "$_local_state" >&2 2>/dev/null || true
return 3
fi
fi
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== set_chrome_profile_appearance DRY-RUN ===" >&2
echo " user-data-dir : ${_udd}" >&2
echo " profile : ${_profile_dir}" >&2
if [[ $_avatar_index -ge 0 ]]; then
echo " avatar : built-in #${_avatar_index} → avatar_icon=chrome://theme/IDR_PROFILE_AVATAR_${_avatar_index}" >&2
echo " is_using_default_avatar=true" >&2
elif [[ -n "$_avatar_image_path" ]]; then
local _dest_img="${_udd}/${_profile_dir}/Google Profile Picture.png"
echo " avatar : imagen custom ${_avatar_image_path}" >&2
echo " copiaría a ${_dest_img}" >&2
echo " is_using_default_avatar=false" >&2
echo " gaia_picture_file_name=Google Profile Picture.png" >&2
fi
if [[ -n "$_color_hex" ]]; then
local _signed_preview
_signed_preview="$(python3 -c "
rgb = int('${_color_hex}', 16)
argb = 0xFF000000 | rgb
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
print(signed)
" 2>/dev/null || echo '?')"
echo " color : #${_color_hex} → signed int32 ${_signed_preview}" >&2
echo " Local State: profile_highlight_color, profile_color_seed, default_avatar_fill_color" >&2
echo " Preferences: browser.theme.user_color2=${_signed_preview}, browser_color_variant=${_variant}, is_grayscale2=false" >&2
echo " Preferences: extensions.theme.system_theme=0" >&2
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
echo " Preferences : ${_prefs_path}" >&2
fi
echo " Local State : ${_local_state}" >&2
printf '{"profile":"%s","avatar_applied":%s,"color_applied":%s,"theme_applied":%s,"variant":%d,"dry_run":true}\n' \
"$_profile_dir" \
"$([[ -n "$_avatar" ]] && echo 'true' || echo 'false')" \
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
"$_variant"
return 0
fi
# ── backup de Local State (no sobreescribir el del mismo día) ────────────
local _today
_today="$(date +%Y%m%d)"
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# ── copiar imagen custom si es necesario ──────────────────────────────────
local _copy_image_done=false
if [[ -n "$_avatar_image_path" ]]; then
local _profile_path="${_udd}/${_profile_dir}"
mkdir -p "$_profile_path"
cp "$_avatar_image_path" "${_profile_path}/Google Profile Picture.png"
_copy_image_done=true
fi
# ── editar Local State con python3 ────────────────────────────────────────
if ! python3 - \
"$_local_state" \
"$_profile_dir" \
"${_avatar_index}" \
"${_avatar_image_path}" \
"${_color_hex}" <<'PY'; then
import sys, json
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
avatar_index = int(sys.argv[3]) # -1 = no cambiar avatar
avatar_img = sys.argv[4] # "" = no usar imagen
color_hex = sys.argv[5] # "" = no cambiar color
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
profile_section = data.setdefault("profile", {})
info_cache = profile_section.setdefault("info_cache", {})
# El perfil debe existir (ya validado en bash, pero doble check)
if prof_dir not in info_cache:
print(f"error: perfil '{prof_dir}' no existe en info_cache", file=sys.stderr)
sys.exit(1)
entry = info_cache[prof_dir]
# ── Avatar ────────────────────────────────────────────────────────────────────
if avatar_index >= 0:
# Avatar built-in: IDR_PROFILE_AVATAR_<N>
entry["avatar_icon"] = f"chrome://theme/IDR_PROFILE_AVATAR_{avatar_index}"
entry["is_using_default_avatar"] = True
elif avatar_img:
# Avatar custom imagen: Chrome necesita gaia_picture_file_name
entry["avatar_icon"] = "chrome://theme/IDR_PROFILE_AVATAR_0"
entry["is_using_default_avatar"] = False
entry["gaia_picture_file_name"] = "Google Profile Picture.png"
# ── Color ─────────────────────────────────────────────────────────────────────
if color_hex:
rgb = int(color_hex, 16) # 0xRRGGBB
argb = 0xFF000000 | rgb # alpha=FF opaco → 0xFFRRGGBB
# Convertir a int32 con signo (Python usa enteros arbitrarios)
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
entry["profile_highlight_color"] = signed
entry["profile_color_seed"] = signed
entry["default_avatar_fill_color"] = signed
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "set_chrome_profile_appearance: error editando Local State con python3" >&2
return 4
fi
# ── validar JSON de Local State tras escritura ────────────────────────────
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "set_chrome_profile_appearance: JSON inválido tras escribir Local State; restaurando backup" >&2
cp "$_backup" "$_local_state"
return 4
fi
# ── editar Preferences del perfil (browser.theme.*) si hay color ─────────
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
local _prefs_backup=""
local _theme_applied=false
if [[ -n "$_color_hex" ]]; then
_theme_applied=true
# Backup de Preferences antes de escribir (mismo patrón que Local State)
if [[ -f "$_prefs_path" ]]; then
_prefs_backup="${_prefs_path}.bak.${_today}"
if [[ ! -f "$_prefs_backup" ]]; then
cp "$_prefs_path" "$_prefs_backup"
fi
fi
# Editar/crear Preferences con python3
if ! python3 - \
"$_prefs_path" \
"${_color_hex}" \
"${_variant}" <<'PY'; then
import sys, json, os
prefs_path = sys.argv[1]
color_hex = sys.argv[2]
variant = int(sys.argv[3])
# Calcular el signed int32 ARGB
rgb = int(color_hex, 16)
argb = 0xFF000000 | rgb
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
# Cargar Preferences existente o arrancar desde vacío
if os.path.isfile(prefs_path):
with open(prefs_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
# ── browser.theme.* ──────────────────────────────────────────────────────────
browser = data.setdefault("browser", {})
theme = browser.setdefault("theme", {})
# Claves modernas (sufijo "2") — verificadas en Chromium 148
theme["user_color2"] = signed
theme["browser_color_variant"] = variant
theme["is_grayscale2"] = False
# Claves legacy (sin sufijo "2") — compatibilidad con versiones anteriores
theme["user_color"] = signed
theme["color_variant"] = variant
theme["is_grayscale"] = False
# ── extensions.theme.system_theme = 0 ────────────────────────────────────────
# 0=color propio, 1=GTK, 2=Qt. Forzar 0 para que el user_color tenga efecto.
extensions = data.setdefault("extensions", {})
ext_theme = extensions.setdefault("theme", {})
ext_theme["system_theme"] = 0
# Escribir directorio si no existe (perfil recién creado sin arrancar)
os.makedirs(os.path.dirname(prefs_path), exist_ok=True)
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "set_chrome_profile_appearance: error editando Preferences con python3" >&2
# Restaurar Preferences si teníamos backup
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
cp "$_prefs_backup" "$_prefs_path"
elif [[ -f "$_prefs_path" ]]; then
rm -f "$_prefs_path"
fi
return 4
fi
# Validar JSON de Preferences tras escritura
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_prefs_path" 2>/dev/null; then
echo "set_chrome_profile_appearance: JSON inválido tras escribir Preferences; restaurando backup" >&2
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
cp "$_prefs_backup" "$_prefs_path"
fi
return 4
fi
fi
# ── leer valores resultantes para el JSON de salida ───────────────────────
local _result_json
_result_json="$(python3 - "$_local_state" "$_profile_dir" "$_prefs_path" "$_theme_applied" "$_variant" <<'PY'
import json, sys, os
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
prefs_path = sys.argv[3]
theme_applied = sys.argv[4] == "true"
variant = int(sys.argv[5])
data = json.load(open(ls_path))
entry = data.get("profile", {}).get("info_cache", {}).get(prof_dir, {})
out = {
"profile": prof_dir,
"avatar_icon": entry.get("avatar_icon", ""),
"is_using_default_avatar": entry.get("is_using_default_avatar", True),
"profile_highlight_color": entry.get("profile_highlight_color", 0),
"profile_color_seed": entry.get("profile_color_seed", 0),
"default_avatar_fill_color": entry.get("default_avatar_fill_color", 0),
"theme_applied": theme_applied,
"variant": variant,
"preferences_path": prefs_path if theme_applied else "",
"backup": "Local State.bak." + __import__("datetime").date.today().strftime("%Y%m%d"),
}
# Añadir valores de theme si se aplicó
if theme_applied and os.path.isfile(prefs_path):
try:
prefs = json.load(open(prefs_path))
bt = prefs.get("browser", {}).get("theme", {})
out["browser_theme_user_color2"] = bt.get("user_color2", 0)
out["browser_theme_color_variant"] = bt.get("browser_color_variant", 0)
out["extensions_theme_system_theme"] = prefs.get("extensions", {}).get("theme", {}).get("system_theme", -1)
except Exception:
pass
print(json.dumps(out, separators=(",",":")))
PY
)"
echo "$_result_json"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set_chrome_profile_appearance "$@"
fi
+42 -44
View File
@@ -3,17 +3,17 @@ name: adb_wsl
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]"
description: "Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador."
tags: ["android", "adb", "wsl", "windows"]
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]"
description: "Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot."
tags: ["android", "adb", "linux", "emulator", "wsl"]
params:
- name: ADB
desc: "Env var opcional. Path absoluto a adb.exe. Si no se fija, se construye desde ANDROID_SDK_WIN o el default /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
- name: ANDROID_SDK_WIN
desc: "Env var opcional. Raiz del Android SDK montado en WSL. Default: /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
output: "Source-able shell helpers: adb_run, adb_devices, adb_wsl_to_win, adb_wait_boot. Define ADB env var apuntando a Windows adb.exe via ANDROID_SDK_WIN."
desc: "Env var opcional. Path absoluto al binario adb (override explicito). Si no se fija, se resuelve Linux-first: $ANDROID_HOME/platform-tools/adb, luego adb del PATH, luego adb.exe si WSL2."
- name: ANDROID_HOME
desc: "Env var opcional. Raiz del Android SDK nativo. Si esta presente, se usa $ANDROID_HOME/platform-tools/adb. Tambien se acepta ANDROID_SDK_ROOT."
output: "Source-able shell helpers: adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot, adb_wsl_to_win. Resuelve y fija la env var ADB al binario adb disponible."
uses_functions: []
uses_types: []
returns: []
@@ -26,24 +26,33 @@ test_file_path: ""
file_path: "bash/functions/infra/adb_wsl.sh"
---
## Uso
## Cuando usarla
Sourcéala como capa base de cualquier script que hable con un device o emulador Android via adb. Es la dependencia comun de todo el toolbelt android del registry (`android_screenshot`, `android_input_*`, `android_logcat`, `android_app_*`, `android_push/pull`). En Linux nativo resuelve el adb del SDK automaticamente; no hace falta configurar nada si `ANDROID_HOME` esta exportado (o `adb` esta en el PATH).
## Ejemplo
```bash
# Sourcear (usa SDK default)
# Linux nativo: con el SDK instalado y ANDROID_HOME exportado, resuelve solo.
source ~/android-sdk/env.sh
source bash/functions/infra/adb_wsl.sh
adb_devices
# List of devices attached
# emulator-5554 device
# Sourcear con SDK custom
ANDROID_SDK_WIN=/mnt/d/Android/Sdk source bash/functions/infra/adb_wsl.sh
# Fijar binario adb explicito (override)
ADB=/opt/android/platform-tools/adb source bash/functions/infra/adb_wsl.sh
# Sourcear con binario fijo
ADB=/mnt/c/my/tools/adb.exe source bash/functions/infra/adb_wsl.sh
# Smoke test
bash bash/functions/infra/adb_wsl.sh --self-test
# Android Debug Bridge version 1.0.41
```
## Funciones expuestas
### `adb_run "<args...>"`
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de `adb.exe`.
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de adb.
```bash
adb_run shell ls /sdcard/
@@ -54,45 +63,34 @@ adb_run install app.apk
Alias de `adb_run devices`. Lista dispositivos/emuladores conectados.
```bash
adb_devices
# List of devices attached
# emulator-5554 device
```
### `adb_pick_serial [--serial <S>] [...]`
### `adb_wsl_to_win <path_wsl>`
Convierte un path WSL a formato Windows con `wslpath -w`. Si `wslpath` no está disponible retorna el path sin convertir.
Resuelve el serial a usar (multi-device). Lee `--serial X` de los args y setea los globals `ADB_PICK_SERIAL` y `ADB_PICK_REST`. Si no se pasa, autoselecciona el primer device/emulador conectado.
```bash
win_path=$(adb_wsl_to_win /home/lucas/proyecto/app.apk)
# C:\Users\lucas\AppData\Local\... (o la ruta Windows equivalente)
adb_run install "$win_path"
adb_pick_serial "$@" || { echo "no device" >&2; exit 3; }
serial="$ADB_PICK_SERIAL"; set -- "${ADB_PICK_REST[@]}"
```
### `adb_s <serial> <args...>`
Atajo de `adb_run -s <serial> <args...>` para multi-device.
### `adb_wait_boot [timeout_s]`
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Útil tras lanzar un AVD en CI.
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Polling cada 3s. Retorna `0` si bootó, `1` si timeout (default 120s).
```bash
adb_wait_boot # timeout 120s
adb_wait_boot 60 # timeout 60s
```
### `adb_wsl_to_win <path_wsl>`
Retorna `0` si el boot se completó, `1` si expiró el timeout.
Legacy WSL: convierte path WSL→Windows con `wslpath -w`. En Linux nativo (sin `wslpath`) devuelve el path tal cual.
## Smoke test
## Gotchas
```bash
bash bash/functions/infra/adb_wsl.sh --self-test
# OK
```
- **Linux-first.** El default ya NO es Windows. Resolucion: `$ADB``$ANDROID_HOME/platform-tools/adb``adb` del PATH → (solo si `/proc/version` indica WSL2) `adb.exe`. En un PC Linux con el SDK instalado funciona sin configurar nada.
- **Necesita el SDK o adb en PATH.** Si no encuentra adb aborta con mensaje a stderr. Instala con `fn run install_android_sdk_bash_infra` y exporta `ANDROID_HOME` (o `source ~/android-sdk/env.sh`).
- **`ADB` se resuelve una sola vez al sourcing.** Cambiar el SDK despues requiere re-sourcear.
- **Sourcéala con bash, no zsh.** Los consumidores usan `${BASH_SOURCE[0]}` para localizar este archivo; ejecutarlos con `bash <file>` (no `zsh`/`source` desde zsh) resuelve el path correctamente.
## Notas
## Capability growth log
- El script es **source-able**: define funciones en el shell actual, no crea subshell.
- `ADB` se resuelve una sola vez al sourcing. Si el binario no existe en disco, la carga falla con mensaje en stderr y `return 1` / `exit 1`.
- `adb_wait_boot` hace polling cada 3 segundos. Ajustar `interval` si el emulador es especialmente lento.
- En WSL2 `wslpath` siempre está disponible; el fallback existe para entornos Linux puros que accidentalmente sourceen el archivo.
- Si el emulador requiere `-s <serial>`, pasar el flag directamente a `adb_run`: `adb_run -s emulator-5554 shell ...`.
---
- v1.1.0 (2026-06-03) — Linux-first: la resolucion de adb ahora prioriza el adb nativo del SDK (`$ANDROID_HOME/platform-tools/adb`) y del PATH; el adb.exe de Windows queda como fallback legacy solo bajo WSL2. Se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. Todo el toolbelt android (~20 funciones) pasa a funcionar en Linux nativo sin preexportar `ADB`.
+25 -10
View File
@@ -1,20 +1,35 @@
#!/usr/bin/env bash
# adb_wsl — Wrapper sourceable para usar adb.exe Windows desde WSL2.
# adb_wsl — Wrapper sourceable para resolver e invocar adb.
# Linux-first: usa el adb nativo del Android SDK o del PATH. Conserva un
# fallback a adb.exe SOLO cuando se detecta WSL2 (legacy). El nombre del
# archivo se mantiene por compatibilidad con sus consumidores del registry.
# Uso: source bash/functions/infra/adb_wsl.sh
# Smoke test: bash bash/functions/infra/adb_wsl.sh --self-test
# ---------------------------------------------------------------------------
# Resolver ADB
# Resolver ADB (Linux-first; fallback WSL legacy)
# ---------------------------------------------------------------------------
# El caller puede fijar ADB antes de sourcing para apuntar a otro binario.
# Prioridad de resolucion:
# 1. $ADB preexportada por el caller (override explicito).
# 2. adb nativo del Android SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT).
# 3. adb del PATH.
# 4. (legacy) adb.exe de Windows, solo si corremos dentro de WSL2.
if [[ -z "${ADB:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
ADB="${_sdk_root}/platform-tools/adb.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
ADB="$_sdk/platform-tools/adb"
elif command -v adb &>/dev/null; then
ADB="$(command -v adb)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
_sdk_win="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}"
ADB="${_sdk_win}/platform-tools/adb.exe"
unset _sdk_win
fi
unset _sdk
fi
if [[ ! -f "$ADB" ]]; then
echo "adb_wsl: ADB no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN= antes de sourcear." >&2
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
echo "adb_wsl: adb no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra), exporta ANDROID_HOME, o fija ADB= antes de sourcear." >&2
# Solo abortamos si el script se ejecuta directamente; si se sourcea,
# permitimos continuar para que el caller maneje el error.
return 1 2>/dev/null || exit 1
@@ -22,8 +37,8 @@ fi
# ---------------------------------------------------------------------------
# adb_run "<args...>"
# Ejecuta el ADB Windows con los argumentos dados.
# Retorna el exit code de adb.exe.
# Ejecuta adb (el binario resuelto en $ADB) con los argumentos dados.
# Retorna el exit code de adb.
# ---------------------------------------------------------------------------
adb_run() {
"$ADB" "$@"
+20 -14
View File
@@ -3,11 +3,11 @@ name: android_emulator_list
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "android_emulator_list([--json])"
description: "Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2."
tags: [android, emulator, wsl]
description: "Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2."
tags: [android, emulator, linux, avd, wsl]
uses_functions: []
uses_types: []
returns: []
@@ -17,35 +17,41 @@ imports: []
params:
- name: "--json"
desc: "Optional flag, outputs JSON array instead of newline-separated names"
output: "Lista de AVDs disponibles en el SDK Windows. Una por linea, o JSON array con --json."
output: "Lista de AVDs disponibles en el SDK. Una por linea, o JSON array con --json."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/android_emulator_list.sh"
notes: "Lee env var EMULATOR o ANDROID_SDK_WIN. Default Windows path: /mnt/c/Users/lucas/AppData/Local/Android/Sdk/emulator/emulator.exe. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe o no es ejecutable."
notes: "Resuelve el binario emulator Linux-first ($ANDROID_HOME/emulator/emulator -> emulator del PATH -> emulator.exe si WSL2). Override con EMULATOR=. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe."
---
## Ejemplo
```bash
source ~/android-sdk/env.sh # exporta ANDROID_HOME
# Listar AVDs (una por linea)
android_emulator_list
# Pixel_API34
# Listar AVDs en formato JSON
android_emulator_list --json
# ["Pixel_7_API_34","Pixel_4_API_30"]
# ["Pixel_API34"]
# Sobreescribir ruta del emulador
EMULATOR="/custom/path/emulator.exe" android_emulator_list
# Sobreescribir SDK base
ANDROID_SDK_WIN="/mnt/d/Android/Sdk" android_emulator_list
EMULATOR="/opt/android/emulator/emulator" android_emulator_list
```
## Notas
## Cuando usarla
El script es ejecutable directamente (`chmod +x`) o invocable con `bash android_emulator_list.sh`.
Antes de arrancar un emulador, para validar que el AVD existe (lo hace `deploy_capacitor_to_emulator` y `run_kotlin_app_tests` internamente). Útil también para listar qué AVDs hay creados en la máquina.
`emulator.exe -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra ademas lineas vacias para producir una lista limpia.
## Gotchas
La variable `EMULATOR` tiene prioridad sobre `ANDROID_SDK_WIN`. Si ninguna esta definida se usa el path Windows por defecto de Lucas.
- **Linux-first.** El default ya no es Windows. Resuelve `$ANDROID_HOME/emulator/emulator`, luego `emulator` del PATH, y solo bajo WSL2 cae a `emulator.exe`.
- `emulator -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra líneas vacías.
- Override del binario con `EMULATOR=`; override del SDK con `ANDROID_HOME=`.
## Capability growth log
- v1.1.0 (2026-06-03) — Linux-first: resuelve el emulator nativo del SDK (`$ANDROID_HOME`) y del PATH antes que `emulator.exe`; se elimina el default hardcodeado `/mnt/c/Users/lucas/...`.
+16 -5
View File
@@ -1,12 +1,23 @@
#!/usr/bin/env bash
# android_emulator_list — Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2.
# android_emulator_list — Lista los AVDs disponibles. Linux-first: usa el
# emulator nativo del Android SDK; fallback a emulator.exe solo bajo WSL2.
set -euo pipefail
# Resolve emulator binary
EMULATOR="${EMULATOR:-${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}/emulator/emulator.exe}"
# Resolve emulator binary (Linux-first; WSL fallback)
if [[ -z "${EMULATOR:-}" ]]; then
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
EMULATOR="$_sdk/emulator/emulator"
elif command -v emulator &>/dev/null; then
EMULATOR="$(command -v emulator)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
fi
unset _sdk
fi
if [[ ! -x "$EMULATOR" ]]; then
echo "error: emulator binary not found or not executable: $EMULATOR" >&2
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
echo "error: emulator no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra) + el paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
exit 1
fi
+23 -13
View File
@@ -3,14 +3,14 @@ name: android_emulator_start
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "android_emulator_start(avd_name: string, timeout_s: int) -> string"
description: "Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro."
tags: [android, emulator, wsl]
description: "Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro."
tags: [android, emulator, linux, avd, wsl]
params:
- name: avd_name
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator.exe -list-avds`)"
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator -list-avds`)"
- name: timeout_s
desc: "Timeout total en segundos para esperar el boot completo. Opcional, default 180"
output: "Serial del device emulado (ej. emulator-5554) en stdout. Exit 0 = boot completo, exit 1 = timeout o emulador murio."
@@ -29,21 +29,31 @@ file_path: "bash/functions/infra/android_emulator_start.sh"
## Ejemplo
```bash
source ~/android-sdk/env.sh # exporta ANDROID_HOME -> resuelve emulator/adb nativos
source bash/functions/infra/android_emulator_start.sh
# Arrancar AVD con timeout por defecto (180s)
serial=$(android_emulator_start "Pixel_6_API_34")
serial=$(android_emulator_start "Pixel_API34")
echo "Emulador listo: $serial" # emulator-5554
# Con timeout personalizado
serial=$(android_emulator_start "Pixel_6_API_34" 300)
serial=$(android_emulator_start "Pixel_API34" 300)
```
## Notas
Para ver la ventana del emulador en un escritorio Linux, exporta `DISPLAY` (y `XAUTHORITY`) antes de invocar.
- Sourcea `adb_wsl.sh` del mismo directorio si existe (provee `ADB`, `adb_run`, `adb_wait_boot`). Si no, usa implementacion inline.
- Resuelve `EMULATOR` y `ADB` desde `ANDROID_SDK_WIN` (default `/mnt/c/Users/lucas/AppData/Local/Android/Sdk`) o desde las variables de entorno `EMULATOR=` / `ADB=` si ya están fijadas.
- Idempotente: si `adb devices` ya muestra un `emulator-*`, imprime "already running" + el serial y sale con exit 0 sin lanzar un segundo proceso.
- Log del emulador en `/tmp/emulator_<avd>.log`. PID en `/tmp/emulator_<avd>.pid`.
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda mitad para esperar `sys.boot_completed=1`.
- Diseñado para WSL2 con Android SDK instalado en Windows. En Linux nativo basta cambiar las rutas de los binarios via `EMULATOR=` y `ADB=`.
## Cuando usarla
Cuando un script necesita un emulador booteado antes de instalar un APK o correr tests instrumentados (`gradle_instrumented_test`, `run_kotlin_app_tests`). Es idempotente, así que se puede llamar al principio de cualquier pipeline sin comprobar antes si ya hay uno arriba.
## Gotchas
- **Linux-first.** Resuelve `EMULATOR`/`ADB` desde `$ANDROID_HOME/{emulator/emulator, platform-tools/adb}` o del PATH; `emulator.exe`/`adb.exe` solo como fallback bajo WSL2. Override manual con `EMULATOR=`/`ADB=`.
- **Necesita `DISPLAY` para ventana.** Sin un servidor X accesible el emulador puede fallar al abrir ventana. Para headless/CI añade `-no-window` (editar la función o lanzar el emulador aparte).
- **Aceleración KVM.** Requiere acceso a `/dev/kvm` (grupo `kvm` o ACL). Sin ella el boot es lentísimo o falla.
- Log del emulador en `/tmp/emulator_<avd>.log`, PID en `/tmp/emulator_<avd>.pid`.
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda para `sys.boot_completed=1`.
## Capability growth log
- v1.1.0 (2026-06-03) — Linux-first: resuelve emulator/adb nativos del SDK (`$ANDROID_HOME`) antes que los `.exe` de Windows (ahora solo fallback WSL2); se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. fix: `timeout <n> adb_run wait-for-device` fallaba siempre porque `timeout` no puede ejecutar la función shell `adb_run`; ahora invoca el binario `"$ADB"` directamente.
+29 -14
View File
@@ -11,11 +11,17 @@ if [[ -f "$_ADB_WSL_SH" ]]; then
# shellcheck source=adb_wsl.sh
source "$_ADB_WSL_SH"
else
# Fallback inline: resolver ADB
# Fallback inline: resolver ADB (Linux-first; WSL fallback)
if [[ -z "${ADB:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
ADB="${_sdk_root}/platform-tools/adb.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
ADB="$_sdk/platform-tools/adb"
elif command -v adb &>/dev/null; then
ADB="$(command -v adb)"
else
ADB="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/platform-tools/adb.exe"
fi
unset _sdk
fi
adb_run() { "$ADB" "$@"; }
adb_wait_boot() {
@@ -33,12 +39,18 @@ else
fi
# ---------------------------------------------------------------------------
# Resolver EMULATOR
# Resolver EMULATOR (Linux-first; WSL fallback)
# ---------------------------------------------------------------------------
if [[ -z "${EMULATOR:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
EMULATOR="${_sdk_root}/emulator/emulator.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
EMULATOR="$_sdk/emulator/emulator"
elif command -v emulator &>/dev/null; then
EMULATOR="$(command -v emulator)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
fi
unset _sdk
fi
# ---------------------------------------------------------------------------
@@ -49,12 +61,12 @@ android_emulator_start() {
local timeout_s="${2:-180}"
# Validaciones de entorno
if [[ ! -f "$EMULATOR" ]]; then
echo "android_emulator_start: emulator.exe no encontrado en '$EMULATOR'. Fija EMULATOR= o ANDROID_SDK_WIN=." >&2
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
echo "android_emulator_start: emulator no encontrado. Instala el SDK + paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
return 1
fi
if [[ ! -f "$ADB" ]]; then
echo "android_emulator_start: adb.exe no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN=." >&2
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
echo "android_emulator_start: adb no encontrado. Instala platform-tools, exporta ANDROID_HOME, o fija ADB=." >&2
return 1
fi
@@ -74,9 +86,12 @@ android_emulator_start() {
local emu_pid=$!
echo "$emu_pid" > "$pid_file"
# Esperar a que el dispositivo aparezca en adb
# Esperar a que el dispositivo aparezca en adb.
# Usamos el binario "$ADB" directamente (no la funcion adb_run): `timeout`
# ejecuta un comando externo y no puede ver funciones del shell, asi que
# `timeout ... adb_run` fallaba siempre con "command not found".
local wait_timeout=$(( timeout_s / 2 ))
if ! timeout "$wait_timeout" adb_run wait-for-device 2>/dev/null; then
if ! timeout "$wait_timeout" "$ADB" wait-for-device 2>/dev/null; then
echo "android_emulator_start: timeout esperando que el dispositivo aparezca en adb (${wait_timeout}s)." >&2
return 1
fi
@@ -0,0 +1,70 @@
---
name: audit_doctor_snapshot
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "audit_doctor_snapshot(doctor_subcommand: string, snapshot_base_dir: string) -> void"
description: "Ejecuta un subcomando de fn doctor --json, guarda un snapshot JSON fechado en <base>/<sub>/<stamp>.json, lo compara con la corrida anterior (latest.json) y emite a stdout un resumen legible: count actual, count previo, IDs nuevos y resueltos. Pieza de observabilidad Nivel 1 para DAGs de auditoría periódica."
tags: [audit, registry, infra, doctor, snapshot, diff, dag]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: doctor_subcommand
desc: "Subcomando de fn doctor a ejecutar (unused, capabilities, artefacts, copied-code, uses-functions, cpp-apps, services, sync, etc.)."
- name: snapshot_base_dir
desc: "Directorio base donde se crea la carpeta <base>/<subcommand>/ con los snapshots fechados y latest.json."
output: "Resumen a stdout: '[audit:<sub>] count=N prev=M +X new -Y resolved'. Si hay IDs nuevos/resueltos, líneas adicionales NEW:/RESOLVED: con hasta 8 IDs. Snapshots JSON en disco."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/audit_doctor_snapshot.sh"
---
## Ejemplo
```bash
# Primera corrida — establece baseline
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
FN_BIN=/home/enmanuel/fn_registry/fn \
bash bash/functions/infra/audit_doctor_snapshot.sh \
unused \
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
# => [audit:unused] count=12 prev=- baseline (sin corrida previa)
# Segunda corrida — compara contra latest.json
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
FN_BIN=/home/enmanuel/fn_registry/fn \
bash bash/functions/infra/audit_doctor_snapshot.sh \
unused \
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
# => [audit:unused] count=12 prev=12 +0 new -0 resolved
# Con otro subcomando (directorio independiente automático)
audit_doctor_snapshot artefacts /tmp/audits/weekly
```
## Cuando usarla
Úsala en un DAG/cron que ejecuta `fn doctor` periódicamente y quieres **persistir el resultado y ver qué cambió desde la última corrida**: funciones huérfanas que aparecieron, artefactos rotos nuevos, capabilities sin doc, etc. Es la pieza "snapshot + diff" del Nivel 1 de observabilidad de auditorías — el DAG llama esta función en vez de descartar el output de `fn doctor`.
## Gotchas
- **Depende de `FN_BIN` o `FN_REGISTRY_ROOT`** en el entorno. Si ninguno está seteado, asume `$HOME/fn_registry/fn`. En DAGs, asegúrate de exportar `FN_REGISTRY_ROOT` antes de invocar.
- **`latest.json` se sobreescribe cada corrida** — es el snapshot de referencia para el diff siguiente. No es un historial acumulado; el historial está en los archivos fechados `<stamp>.json`.
- **Si cambias de subcomando, el subdirectorio es distinto** (`<base>/unused/` vs `<base>/artefacts/`), así que no hay contaminación entre subcomandos aunque compartan el mismo `base_dir`.
- **Si `fn doctor <sub>` falla (rc != 0)**, la función propaga ese exit code. Esto es intencional: doctor roto = problema real que el DAG debe reportar. Los hallazgos normales (funciones huérfanas, artefactos con drift) tienen rc=0 en `fn doctor`.
- **jq es dependencia requerida**. Está disponible en el ecosistema del registry pero si el entorno no lo tiene, los conteos y diffs de IDs caen a `?`/textual respectivamente.
- **Retención automática**: snapshots fechados con más de 30 días se borran con `find -mtime +30`. `latest.json` nunca se borra.
- **Estructura del JSON de `fn doctor`**: el diff de IDs busca campos `.ID` o `.id` en los elementos. Si el subcomando produce una estructura distinta (objeto anidado sin esos campos), el diff cae a comparación textual, que sigue siendo útil.
## Notas
Diseñada para ser invocada desde steps del dag_engine (`daily-registry-audit`, `weekly-deep-scan`) como reemplazo del descarte silencioso del output de `fn doctor --json`. La salida stdout es legible por humanos y parseable por el orquestador del DAG para decidir si crear proposals.
Binario `fn` resuelto en orden: `$FN_BIN``${FN_REGISTRY_ROOT}/fn``$HOME/fn_registry/fn`.
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# audit_doctor_snapshot — ejecuta un subcomando de fn doctor, guarda snapshot JSON
# fechado, compara con la corrida anterior y emite resumen legible de cambios.
#
# Uso: audit_doctor_snapshot <doctor_subcommand> <snapshot_base_dir>
#
# Ejemplo:
# audit_doctor_snapshot unused /home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
set -uo pipefail
audit_doctor_snapshot() {
local sub="${1:-}"
local base="${2:-}"
# --- validacion de argumentos ---
if [[ -z "$sub" || -z "$base" ]]; then
echo "usage: audit_doctor_snapshot <subcommand> <base_dir>" >&2
return 2
fi
# --- resolver binario fn ---
local fn_bin="${FN_BIN:-${FN_REGISTRY_ROOT:-$HOME/fn_registry}/fn}"
if [[ ! -x "$fn_bin" ]]; then
echo "audit_doctor_snapshot: binario fn no encontrado o no ejecutable: $fn_bin" >&2
return 2
fi
# --- preparar directorio ---
local dir="$base/$sub"
mkdir -p "$dir"
# --- ejecutar fn doctor ---
local stderr_tmp
stderr_tmp="$(mktemp /tmp/audit_doctor_snapshot_stderr.XXXXXX)"
local json rc
json="$("$fn_bin" doctor "$sub" --json 2>"$stderr_tmp")" || rc=$?
rc="${rc:-0}"
if [[ "$rc" -ne 0 ]]; then
cat "$stderr_tmp" >&2
echo "audit_doctor_snapshot: 'fn doctor $sub' fallo (rc=$rc)" >&2
rm -f "$stderr_tmp"
return "$rc"
fi
rm -f "$stderr_tmp"
# --- normalizar con jq (diff estable) ---
local stamp
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
local curr="$dir/${stamp}.json"
local nojson=0
if ! echo "$json" | jq -S . > "$curr" 2>/dev/null; then
# salida no es JSON valido -> guardar crudo
printf '%s' "$json" > "$curr"
nojson=1
fi
# --- snapshot anterior ---
local prev="$dir/latest.json"
# --- contar hallazgos actuales ---
local count="?"
if [[ "$nojson" -eq 0 ]]; then
if jq -e 'type == "array"' "$curr" >/dev/null 2>&1; then
count="$(jq 'length' "$curr")"
elif jq -e 'type == "object"' "$curr" >/dev/null 2>&1; then
count="$(jq 'keys | length' "$curr")"
fi
fi
# --- contar hallazgos previos ---
local prevcount="-"
if [[ -f "$prev" ]]; then
if jq -e 'type == "array"' "$prev" >/dev/null 2>&1; then
prevcount="$(jq 'length' "$prev")"
elif jq -e 'type == "object"' "$prev" >/dev/null 2>&1; then
prevcount="$(jq 'keys | length' "$prev")"
fi
fi
# --- diff de identidad ---
local new_count=0
local resolved_count=0
local new_ids=()
local resolved_ids=()
local diff_label=""
if [[ ! -f "$prev" ]]; then
diff_label="baseline (sin corrida previa)"
elif [[ "$nojson" -eq 1 ]]; then
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
diff_label="changed (textual)"
else
diff_label="+0 new -0 resolved"
fi
else
# extraer IDs estables: .ID o .id
local curr_ids prev_ids
curr_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$curr" 2>/dev/null | sort -u)"
prev_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$prev" 2>/dev/null | sort -u)"
if [[ -n "$curr_ids" || -n "$prev_ids" ]]; then
# NEW: en curr pero no en prev
local new_raw resolved_raw
new_raw="$(comm -23 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
resolved_raw="$(comm -13 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
if [[ -n "$new_raw" ]]; then
mapfile -t new_ids <<< "$new_raw"
fi
if [[ -n "$resolved_raw" ]]; then
mapfile -t resolved_ids <<< "$resolved_raw"
fi
new_count="${#new_ids[@]}"
resolved_count="${#resolved_ids[@]}"
diff_label="+${new_count} new -${resolved_count} resolved"
else
# sin campo .ID/.id — fallback textual
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
diff_label="changed (textual)"
else
diff_label="+0 new -0 resolved"
fi
fi
fi
# --- resumen a stdout ---
echo "[audit:$sub] count=$count prev=$prevcount $diff_label"
# listar nuevos (max 8)
if [[ "${#new_ids[@]}" -gt 0 ]]; then
local listed=("${new_ids[@]:0:8}")
local extra=$(( ${#new_ids[@]} - 8 ))
local line
line="$(IFS=', '; echo "${listed[*]}")"
if [[ "$extra" -gt 0 ]]; then
line="${line} (+${extra} más)"
fi
echo " NEW: $line"
fi
# listar resueltos (max 8)
if [[ "${#resolved_ids[@]}" -gt 0 ]]; then
local listed_r=("${resolved_ids[@]:0:8}")
local extra_r=$(( ${#resolved_ids[@]} - 8 ))
local line_r
line_r="$(IFS=', '; echo "${listed_r[*]}")"
if [[ "$extra_r" -gt 0 ]]; then
line_r="${line_r} (+${extra_r} más)"
fi
echo " RESOLVED: $line_r"
fi
# --- actualizar puntero latest ---
cp "$curr" "$prev"
# --- retención: borrar snapshots fechados > 30 días ---
find "$dir" -maxdepth 1 -name '*.json' ! -name 'latest.json' -mtime +30 -delete 2>/dev/null || true
return 0
}
# Permitir ejecución directa
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
audit_doctor_snapshot "$@"
fi
+1 -1
View File
@@ -32,7 +32,7 @@ discover_git_repos() {
-not -path "*/node_modules/*" \
-not -path "*/.venv/*" \
-not -path "*/cpp/vendor/*" \
-not -path "*/cpp/build/*" \
-not -path "*/cpp/build*/*" \
-not -path "*/sources/*" \
-not -path "*/temp/*" \
-not -path "*/subrepos/*" \
@@ -0,0 +1,58 @@
---
name: ensure_project_gitignore
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "ensure_project_gitignore(project_dir: string) -> void"
description: "Garantiza de forma idempotente que el .gitignore de un directorio de project contiene las lineas canonicas que excluyen del repo del project el contenido de sus sub-repos hijos (apps y analyses son repos Gitea independientes) y sus vaults (datos fuera de git). Evita el doble-tracking al hacer push del project."
tags: [git, gitignore, projects, infra]
params:
- name: project_dir
desc: "Ruta al directorio del project (p. ej. projects/aurgi). Debe existir; si no, error a stderr y return 1. El .gitignore se escribe/actualiza en <project_dir>/.gitignore."
output: "Sin salida en stdout. A stderr informa de la accion realizada: 'created' si creo el .gitignore, 'updated: anadidas N lineas' si anadio lineas faltantes, u 'ok: ya completo' si nada cambiaba. Codigo de salida 0 en exito, 1 si project_dir falta o no existe."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/ensure_project_gitignore.sh"
---
## Ejemplo
```bash
source bash/functions/infra/ensure_project_gitignore.sh
# Asegura que projects/aurgi/.gitignore excluye el contenido de sus hijos.
ensure_project_gitignore projects/aurgi
# stderr: ensure_project_gitignore: created projects/aurgi/.gitignore
# (o: updated: anadidas 2 lineas / ok: ya completo)
```
Las lineas canonicas que la funcion garantiza son:
```
apps/*/
analysis/*/
vaults/*
!vaults/.gitkeep
!vaults/vault.yaml
```
## Cuando usarla
Llamala justo despues de crear un project nuevo (`mkdir -p projects/<nombre>/{apps,analysis,vaults}`) y antes de inicializar su repo Gitea con `ensure_repo_synced`, para que el repo del project nunca trackee el contenido de sus sub-repos hijos. Tambien al adoptar un project existente que aun no tiene estas exclusiones, o como paso de saneamiento cuando `git status` del project muestra contenido de `apps/`/`analysis/` que deberia estar ignorado.
## Gotchas
- La funcion modifica el filesystem (escribe en `<project_dir>/.gitignore`): es impura. No commitea ni hace push — solo deja el `.gitignore` correcto.
- La comparacion para no duplicar es linea-exacta (`grep -Fxq`). Una linea equivalente pero con espacios extra, comentario adjunto o glob distinto (p. ej. `apps/*` sin la barra final) NO se considera presente y la canonica se anade igualmente; podrian quedar ambas formas. Mantener el `.gitignore` con las lineas canonicas tal cual evita ruido.
- Si el `.gitignore` existente no termina en salto de linea, la funcion anade uno antes de apendar para no pegar la primera linea nueva al final de la ultima existente.
- Solo gestiona las exclusiones de sub-repos hijos y vaults del nivel-project; no toca otras reglas que el `.gitignore` ya contenga ni las reordena.
- Si una linea canonica ya existia con su forma exacta, no se vuelve a anadir (idempotente): re-ejecutar es seguro.
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# ensure_project_gitignore — Garantiza de forma idempotente que el .gitignore de
# un directorio de project (projects/<nombre>/) contiene las lineas canonicas que
# excluyen del repo del project el contenido de sus sub-repos hijos (apps y
# analyses son repos Gitea independientes) y sus vaults (datos fuera de git).
#
# Esto evita que al hacer push del project se trackee por error el contenido de
# los hijos (doble-tracking). Ver .claude/rules/apps_subrepo.md y
# .claude/rules/projects.md.
#
# Uso:
# ensure_project_gitignore <project_dir>
#
# Salida:
# stdout vacio. A stderr informa de la accion realizada (created / updated / ok).
ensure_project_gitignore() {
local project_dir="$1"
if [[ -z "$project_dir" ]]; then
echo "ensure_project_gitignore: se requiere project_dir" >&2
return 1
fi
if [[ ! -d "$project_dir" ]]; then
echo "ensure_project_gitignore: directorio '$project_dir' no existe" >&2
return 1
fi
local gitignore="$project_dir/.gitignore"
# Lineas canonicas que deben estar presentes (orden de referencia).
local -a canonical=(
"apps/*/"
"analysis/*/"
"vaults/*"
"!vaults/.gitkeep"
"!vaults/vault.yaml"
)
# Caso 1: el .gitignore no existe — crearlo con el contenido canonico.
if [[ ! -f "$gitignore" ]]; then
printf '%s\n' "${canonical[@]}" > "$gitignore"
echo "ensure_project_gitignore: created $gitignore" >&2
return 0
fi
# Caso 2: existe — anadir solo las lineas que falten (comparacion linea-exacta),
# preservando el contenido y el orden existentes.
# Si el archivo no termina en newline, anadir uno antes de apendar para no
# pegar la primera linea nueva al final de la ultima existente.
if [[ -s "$gitignore" && -n "$(tail -c 1 "$gitignore")" ]]; then
printf '\n' >> "$gitignore"
fi
local line added=0
for line in "${canonical[@]}"; do
# grep -F -x: match literal de linea completa, sin interpretar metacaracteres.
if ! grep -Fxq -- "$line" "$gitignore"; then
printf '%s\n' "$line" >> "$gitignore"
added=$((added + 1))
fi
done
if [[ $added -gt 0 ]]; then
echo "ensure_project_gitignore: updated: anadidas $added lineas a $gitignore" >&2
else
echo "ensure_project_gitignore: ok: ya completo $gitignore" >&2
fi
return 0
}
# Si se invoca como script (no source), ejecutar la funcion.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
ensure_project_gitignore "$@"
fi
+16 -1
View File
@@ -3,7 +3,7 @@ name: install_android_sdk
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.0.1"
purity: impure
signature: "install_android_sdk() -> void"
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
@@ -50,6 +50,17 @@ ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
source ~/android-sdk/env.sh
```
## Cuando usarla
Cuando necesites un Android SDK funcional en una maquina Linux sin permisos de root: CI, contenedores, o un PC de desarrollo donde quieras un SDK aislado en `$HOME`. Instala la base minima para compilar (cmdline-tools + JDK 17 + platform-tools + API 34 + build-tools). Hazle `source` para tener `sdkmanager`/`avdmanager`/`adb` en el PATH antes de invocar `gradle_run`, `gradle_assemble_debug` o `capacitor_build_apk`.
## Gotchas
- **No instala `emulator` ni system images.** Solo la base de compilacion. Para correr un AVD: tras hacer `source env.sh`, instala `emulator` y una imagen (`sdkmanager "emulator" "system-images;android-34;google_apis;x86_64"`) y crea el AVD con `avdmanager create avd`.
- **Aceleracion KVM:** el emulador necesita acceso a `/dev/kvm`. Verifica con `[ -w /dev/kvm ]`; si no, anade tu usuario al grupo `kvm` (`sudo usermod -aG kvm $USER` + re-login) o concede ACL.
- **URL de cmdline-tools clavada** a la build 11076708 (2024). Si Google la retira, actualizar `tools_url` en el `.sh`.
- **Idempotente:** re-ejecutar no reinstala; detecta `sdkmanager` existente y sale en 0.
## Notas
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
@@ -61,3 +72,7 @@ La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools co
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
## Capability growth log
- v1.0.1 (2026-06-03) — fix: `yes | sdkmanager --licenses` daba falso negativo bajo `pipefail` (SIGPIPE de `yes`, exit 141) abortando una instalacion exitosa; ahora se desactiva `pipefail` solo en ese pipe. fix: el trap `EXIT` referenciaba `$tmp_dir` (variable `local`) fuera del scope de la funcion → "unbound variable" con `set -u`; ahora es global con expansion defensiva.
+13 -3
View File
@@ -5,11 +5,14 @@ set -euo pipefail
install_android_sdk() {
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
local tmp_dir
# tmp_dir es global a proposito: el trap EXIT se dispara al terminar el
# script (fuera del scope de la funcion), donde una variable `local` ya no
# existiria y `set -u` la marcaria como unbound. La expansion defensiva
# ${tmp_dir:-} evita el fallo aunque el trap corra antes de la asignacion.
tmp_dir="$(mktemp -d)"
# Limpia temporales al salir
trap 'rm -rf "$tmp_dir"' EXIT
trap 'rm -rf "${tmp_dir:-}"' EXIT
# 1. Verifica si ya está instalado
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
@@ -103,11 +106,18 @@ install_android_sdk() {
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
# 4. Acepta licencias e instala paquetes necesarios
# `yes` recibe SIGPIPE (exit 141) cuando sdkmanager termina de leer y cierra
# el pipe; bajo `set -o pipefail` eso convierte un exito real en falso
# negativo. Desactivamos pipefail solo aqui para que el exit del pipeline
# refleje el de sdkmanager (ultimo comando), no el SIGPIPE de `yes`.
echo "Aceptando licencias de Android SDK..."
if ! yes | "$sdkmanager" --licenses; then
set +o pipefail
if ! yes | "$sdkmanager" --licenses >/dev/null 2>&1; then
set -o pipefail
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
return 1
fi
set -o pipefail
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
@@ -0,0 +1,66 @@
---
name: launch_claude_agent_kitty
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "launch_claude_agent_kitty(title: string, directory: string, prompt_file: string) -> string"
description: "Lanza un Claude Code secundario interactivo y persistente en su propia terminal kitty, con un prompt autonomo inyectado desde un archivo y --dangerously-skip-permissions. Mecanica del modo orquestador: un Claude principal descompone una tarea y lanza N secundarios, cada uno en su kitty, que el humano ve y puede retomar. La ventana sobrevive al cierre de la terminal padre (setsid nohup ... disown) y deja una shell interactiva viva cuando el claude termina (exec zsh)."
tags: [orchestration, agents, claude, kitty, agent, terminal, infra]
params:
- name: title
desc: "Titulo de la ventana kitty. Ej: 'fn_registry · subtarea X'. Tambien se sanitiza (minusculas, no-alfanumerico -> '_') para derivar el slug del archivo de log."
- name: directory
desc: "Directorio de trabajo AISLADO donde arranca el claude secundario (worktree git, sub-repo, o dir cualquiera). Debe existir; si no -> error exit 2. Usar un dir aislado: dos claudes en el mismo working tree comparten HEAD y dispersan commits."
- name: prompt_file
desc: "Ruta a un archivo .md con el prompt autonomo a inyectar (ej. /tmp/orq_<slug>.md). Debe existir y ser legible; si no -> error exit 2."
output: "Imprime en stdout el title, directory, prompt_file y la ruta del log (/tmp/orq_<slug>_kitty.log) donde se ve el arranque. Exit 0 = lanzamiento disparado; exit 2 = argumentos invalidos; exit 1 = kitty no instalado."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/launch_claude_agent_kitty.sh"
---
## Ejemplo
```bash
source bash/functions/infra/launch_claude_agent_kitty.sh
# El orquestador prepara un worktree aislado y un archivo de prompt...
git worktree add /tmp/orq_docs_wt -b orq/docs
cat > /tmp/orq_docs.md <<'PROMPT'
Eres un agente secundario. Tu tarea: revisar y mejorar la documentacion del
dominio infra del registry. Trabaja SOLO en este worktree. Reporta al terminar.
PROMPT
# ...y lanza un claude secundario en su propia kitty:
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
# -> abre una ventana kitty titulada "fn_registry · docs", arranca claude con
# el prompt inyectado, y deja /tmp/orq_fn_registry_docs_kitty.log con el arranque.
```
O directo via `fn run`:
```bash
./fn run launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
```
## Cuando usarla
Cuando el orquestador quiere lanzar un Claude secundario **interactivo** en su propia terminal kitty para una sub-tarea que el humano quiere **ver y poder retomar**. A diferencia del `Agent` tool (sub-agente no interactivo, headless, cuyo output vuelve al padre y no deja terminal abierta), aqui cada secundario corre en una ventana visible que persiste: el humano observa el progreso en vivo y, cuando el claude termina, la shell sigue ahi para continuar manualmente o relanzar.
## Gotchas
- **kitty debe estar instalado.** Si `command -v kitty` falla -> exit 1 con mensaje claro. No hay fallback a otra terminal.
- **El `directory` debe ser AISLADO** (worktree git o sub-repo propio). Dos claudes apuntando al mismo working tree **comparten HEAD** y dispersan/cruzan los commits (memoria `multi-agent-git-race-same-repo`). El orquestador debe crear un worktree/clon por agente antes de llamar.
- **`--dangerously-skip-permissions` corre sin pedir confirmacion** a ninguna accion (memoria `lanzar-agentes-skip-permissions`). Es a proposito para agentes autonomos desatendidos, pero es un riesgo asumido: el secundario puede tocar el sistema sin gates. No lanzar sobre directorios sensibles.
- **El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque** (errores de kitty/claude al iniciar). El `<slug>` deriva del `title` sanitizado; titulos distintos que colapsen al mismo slug sobrescriben el mismo log.
- **El PID reportado no es el de kitty.** Con `setsid` el `$!` es el del proceso setsid, no el de la ventana; por eso la funcion reporta el log en vez de un PID. Para encontrar la ventana despues: `pgrep -af kitty | grep <title>`.
- **El prompt se inyecta con `"$(cat <prompt_file>)"` evaluado DENTRO de la kitty.** Si editas el `prompt_file` despues de lanzar pero antes de que la kitty arranque, el claude vera la version editada (se lee en el momento del arranque, no del lanzamiento).
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# launch_claude_agent_kitty — Lanza un Claude Code secundario interactivo y
# persistente en su propia terminal kitty, con un prompt autonomo inyectado
# desde un archivo. Es la mecanica de lanzamiento del "modo orquestador": un
# Claude principal descompone una tarea y lanza N secundarios, cada uno en su
# kitty, que el humano ve y puede retomar.
#
# Mecanismo:
# - setsid nohup kitty ... & disown -> la ventana sobrevive al cierre de la
# terminal padre (igual que reboot_all_claudes con setsid).
# - zsh -ic 'claude ...; exec zsh' -> al terminar el claude queda una shell
# interactiva viva para que el humano siga en esa terminal.
# - --dangerously-skip-permissions -> agente autonomo desatendido (sin
# confirmaciones). Riesgo asumido a proposito.
# - El prompt se inyecta con "$(cat <prompt_file>)" para no expandir nada en
# el shell del orquestador.
# - Log de arranque en /tmp/orq_<slug>_kitty.log, donde <slug> deriva del
# title (minusculas, no-alfanumerico -> '_').
set -euo pipefail
IFS=$' \t\n'
launch_claude_agent_kitty() {
# -----------------------------------------------------------------------
# Ayuda / sin argumentos.
# -----------------------------------------------------------------------
if [[ $# -eq 0 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'USAGE'
Uso: launch_claude_agent_kitty <title> <directory> <prompt_file>
Lanza un Claude Code secundario interactivo y persistente en su propia
terminal kitty, con el prompt del archivo <prompt_file> inyectado y
--dangerously-skip-permissions (agente autonomo desatendido).
Argumentos (los 3 obligatorios):
title Titulo de la ventana kitty. Ej: "fn_registry · subtarea X".
directory Directorio de trabajo AISLADO donde arranca el claude
secundario (worktree git, sub-repo, o dir cualquiera). Debe
existir. Usa un dir aislado: dos claudes en el mismo working
tree comparten HEAD y dispersan commits.
prompt_file Ruta a un archivo .md con el prompt autonomo a inyectar.
Debe existir y ser legible.
Ejemplo:
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
El log de arranque va a /tmp/orq_<slug>_kitty.log (slug derivado del title).
USAGE
return 0
fi
# -----------------------------------------------------------------------
# Validacion de argumentos.
# -----------------------------------------------------------------------
if [[ $# -ne 3 ]]; then
echo "launch_claude_agent_kitty: se requieren 3 argumentos <title> <directory> <prompt_file> (recibidos: $#). Usa -h." >&2
return 2
fi
local title="$1"
local directory="$2"
local prompt_file="$3"
if [[ -z "$title" ]]; then
echo "launch_claude_agent_kitty: <title> no puede estar vacio." >&2
return 2
fi
if [[ ! -d "$directory" ]]; then
echo "launch_claude_agent_kitty: el directorio de trabajo no existe: '$directory'." >&2
return 2
fi
if [[ ! -f "$prompt_file" ]]; then
echo "launch_claude_agent_kitty: el prompt_file no existe: '$prompt_file'." >&2
return 2
fi
if [[ ! -r "$prompt_file" ]]; then
echo "launch_claude_agent_kitty: el prompt_file no es legible: '$prompt_file'." >&2
return 2
fi
# -----------------------------------------------------------------------
# Comprobar que kitty esta instalado.
# -----------------------------------------------------------------------
if ! command -v kitty >/dev/null 2>&1; then
echo "launch_claude_agent_kitty: 'kitty' no esta instalado o no esta en el PATH." >&2
return 1
fi
# -----------------------------------------------------------------------
# Derivar el slug del title para el nombre del log.
# minusculas, todo no-alfanumerico -> '_', colapsar/recortar '_'.
# -----------------------------------------------------------------------
local slug
slug="$(printf '%s' "$title" \
| tr '[:upper:]' '[:lower:]' \
| tr -c 'a-z0-9' '_' \
| sed -E 's/_+/_/g; s/^_//; s/_$//')"
[[ -z "$slug" ]] && slug="agent"
local log="/tmp/orq_${slug}_kitty.log"
# -----------------------------------------------------------------------
# Lanzar la kitty detached. El prompt se inyecta con "$(cat <prompt_file>)"
# ya escapado para que se evalue DENTRO de la kitty, no aqui.
# exec zsh deja una shell viva cuando el claude termina.
# -----------------------------------------------------------------------
local inner
inner="claude --dangerously-skip-permissions \"\$(cat $(printf '%q' "$prompt_file"))\"; exec zsh"
setsid nohup kitty \
--title "$title" \
--directory "$directory" \
zsh -ic "$inner" \
>"$log" 2>&1 &
disown 2>/dev/null || true
# -----------------------------------------------------------------------
# Reportar. Con setsid el $! es el PID de setsid, no el de kitty; basta
# con confirmar el lanzamiento y apuntar al log donde se ve el arranque.
# -----------------------------------------------------------------------
echo "launch_claude_agent_kitty: claude secundario lanzado."
echo " title: $title"
echo " directory: $directory"
echo " prompt_file: $prompt_file"
echo " log: $log"
echo " (sigue el arranque con: tail -f $(printf '%q' "$log"))"
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
launch_claude_agent_kitty "$@"
fi
@@ -0,0 +1,55 @@
---
name: list_claude_agents
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "list_claude_agents([--json] [--exclude-current] [-h|--help])"
description: "Lista todas las instancias de Claude Code VIVAS cruzando pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json. Para cada claude vivo y validado devuelve PID, status (idle/busy), etime (tiempo de vida), KITTY_PID de su ventana kitty, sessionId y cwd. Es la herramienta de seguimiento de la flota del modo orquestador: el Claude principal ve que agentes secundarios siguen vivos, en que directorio trabajan y su sessionId para retomarlos con claude --resume."
tags: [orchestration, claude, session, fleet, kitty, infra, terminal-capture]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--json"
desc: "Imprime un array JSON (un objeto por agente con pid, session_id, cwd, status, etime, kitty_pid, self) en vez de la tabla legible. Pensado para que el agente parsee y decida cual retomar/parar."
- name: "--exclude-current"
desc: "Omite la propia sesion del listado. Detecta el claude propio subiendo por la cadena de ancestros de $$ hasta hallar un proceso con comm=claude. Sin esta opcion, la sesion actual se marca (columna SELF en tabla / self=true en JSON)."
- name: "-h|--help"
desc: "Muestra el uso y termina con exit 0."
output: "En modo tabla: una fila por claude vivo y validado con columnas PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD. En modo --json: array JSON con pid, session_id, cwd, status, etime, kitty_pid (null si no corre en kitty) y self. Si no hay claudes vivos imprime aviso (tabla) o [] (json) y exit 0. Exit 0 normal; exit 2 si flag invalido."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/list_claude_agents.sh"
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart (parseado con python3); validacion en tres capas: kill -0 <PID> exito, el JSON existe, y anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat (si difieren el JSON es huerfano de un PID reusado y se omite). KITTY_PID se saca del environ del proceso (tr '\\0' '\\n' < /proc/<PID>/environ | sed -n 's/^KITTY_PID=//p'). etime via ps -o etime= -p <PID>. Reusa la misma logica de descubrimiento y validacion que reboot_all_claudes_bash_infra. El codigo JSON va en python3 -c con los datos por stdin TSV (no heredoc) para no colisionar el stdin del pipe."
---
## Ejemplo
```bash
# Tabla legible de la flota de Claudes vivos (PID, status, etime, kitty, sessionId, cwd).
./fn run list_claude_agents
# Array JSON para parsear (decidir cual retomar con claude --resume <session_id>).
./fn run list_claude_agents --json
# Omitir la propia sesion (ver solo los agentes secundarios).
./fn run list_claude_agents --exclude-current
```
## Cuando usarla
Cuando el orquestador necesita ver la flota de Claudes secundarios vivos (PID, cwd, sessionId, status) para seguir su progreso o decidir cual retomar/parar. Lanzala al inicio de un ciclo de seguimiento para saber que agentes siguen activos y en que directorio trabaja cada uno; usa `--json` cuando vayas a programar la decision (filtrar por `status`, extraer `session_id` para un `claude --resume`).
## Gotchas
- **Requiere Claude Code >= 2.1.x.** Depende de que cada sesion escriba `~/.claude/sessions/<PID>.json` con los campos `sessionId`, `cwd`, `status`, `procStart`. Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
- **Un JSON puede ser huerfano por PID reciclado.** El sistema operativo reusa PIDs; un `<PID>.json` viejo puede apuntar a un proceso `claude` distinto. Por eso se valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide la entrada se descarta. Sin esa validacion se reportarian agentes fantasma.
- **El titulo exacto de la ventana kitty no se recupera sin `kitty @`.** Se reporta el `KITTY_PID` (suficiente para identificar la ventana); mapearlo al titulo requeriria `kitty @ ls`, que solo funciona si el control remoto de kitty esta habilitado. KISS: se omite por defecto. Un claude que corra fuera de kitty (terminal integrado de un editor, etc.) sale con `KITTY` vacio `(none)` / `kitty_pid: null`.
- **Solo ve procesos del usuario actual.** `pgrep -x claude` y la lectura de `/proc/<PID>/{environ,stat}` solo cubren los claudes del propio usuario; no lista sesiones de otros usuarios del sistema.
- **`status` refleja el ultimo estado guardado en el JSON**, no necesariamente el instante exacto de la consulta (Claude actualiza el archivo al cambiar de estado). Pueden aparecer valores como `idle`, `busy` o `waiting`.
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# list_claude_agents — Lista todas las instancias de Claude Code VIVAS cruzando
# pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json.
# Para cada claude vivo y validado reporta: PID, status (idle/busy), etime (tiempo de
# vida del proceso), KITTY_PID de la ventana kitty si corre en una, sessionId y cwd.
# Es la herramienta de "seguimiento de la flota" del modo orquestador: el Claude
# principal la usa para ver que agentes secundarios siguen vivos, en que directorio
# trabajan y su sessionId (para poder retomarlos con claude --resume <sessionId>).
#
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito y el JSON debe existir.
# - KITTY_PID del environ del proceso -> ventana kitty (titulo exacto requeriria
# 'kitty @ ls'; aqui se reporta el KITTY_PID, suficiente para identificarla).
# - etime via ps -o etime= -p <PID>.
set -euo pipefail
IFS=$' \t\n'
list_claude_agents() {
local output="table" # table | json
local exclude_current=0
# -----------------------------------------------------------------------
# Parseo de argumentos
# -----------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
output="json"
;;
--exclude-current)
exclude_current=1
;;
-h|--help)
cat <<'USAGE'
Uso: list_claude_agents [--json] [--exclude-current]
Lista las instancias de Claude Code vivas y validas, una fila por agente, con su
PID, status, etime (tiempo de vida), KITTY_PID, sessionId y cwd. Pensada para el
modo orquestador: ver la flota de Claudes secundarios y su sessionId para retomar
(claude --resume <sessionId>) o decidir cual parar.
Opciones:
--json Imprime un array JSON (pid, session_id, cwd, status, etime,
kitty_pid) en vez de la tabla. Util para parsear.
--exclude-current Omite la propia sesion (sube por la cadena de ancestros de
$$ hasta hallar un proceso con comm=claude). Sin esta opcion,
la sesion actual se marca con self=true / SELF en la tabla.
-h, --help Muestra esta ayuda.
Ejemplos:
list_claude_agents
list_claude_agents --json
list_claude_agents --exclude-current
USAGE
return 0
;;
*)
echo "list_claude_agents: opcion desconocida: '$1' (usa -h)" >&2
return 2
;;
esac
shift
done
# -----------------------------------------------------------------------
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
# Se usa tanto para --exclude-current como para marcar la fila propia.
# -----------------------------------------------------------------------
local current_claude_pid=""
local walk="$$"
local guard=0
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
local comm=""
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
if [[ "$comm" == "claude" ]]; then
current_claude_pid="$walk"
break
fi
# campo 4 de /proc/<pid>/stat es el PPID
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
guard=$((guard + 1))
[[ "$guard" -gt 64 ]] && break
done
# -----------------------------------------------------------------------
# Recolectar las sesiones vivas y validarlas.
# -----------------------------------------------------------------------
local sessions_dir="$HOME/.claude/sessions"
local pids=""
pids="$(pgrep -x claude 2>/dev/null || true)"
if [[ -z "$pids" ]]; then
if [[ "$output" == "json" ]]; then
echo "[]"
else
echo "list_claude_agents: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
fi
return 0
fi
# Arrays paralelos con la flota validada.
local -a a_pid a_status a_etime a_kitty a_sid a_cwd a_self
local pid
for pid in $pids; do
# Validacion 1: el proceso debe seguir vivo.
if ! kill -0 "$pid" 2>/dev/null; then
continue
fi
# Validacion 2: debe existir su JSON de sesion.
local json="$sessions_dir/$pid.json"
if [[ ! -f "$json" ]]; then
continue
fi
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
# Salida: lineas "clave=valor" en orden fijo.
local parsed=""
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
import json, sys
try:
with open(sys.argv[1]) as fh:
d = json.load(fh)
except Exception:
sys.exit(0)
print("sessionId=" + str(d.get("sessionId", "")))
print("cwd=" + str(d.get("cwd", "")))
print("status=" + str(d.get("status", "")))
print("procStart=" + str(d.get("procStart", "")))
PY
)"
[[ -z "$parsed" ]] && continue
local sid cwd status proc_start_json
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
[[ -z "$sid" ]] && continue
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
# con el campo 22 de /proc/<PID>/stat.
local proc_start_real=""
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
# JSON huerfano de un PID reciclado: omitir.
continue
fi
# Omitir la propia sesion si se pidio --exclude-current.
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
continue
fi
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
local kitty_pid=""
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
# etime: tiempo transcurrido desde que arranco el proceso.
local etime=""
etime="$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
# Marca de sesion propia (solo relevante cuando NO se excluye).
local self="false"
if [[ -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
self="true"
fi
a_pid+=("$pid")
a_status+=("${status:-?}")
a_etime+=("${etime:-?}")
a_kitty+=("${kitty_pid:-}")
a_sid+=("$sid")
a_cwd+=("${cwd:-?}")
a_self+=("$self")
done
local total="${#a_pid[@]}"
if [[ "$total" -eq 0 ]]; then
if [[ "$output" == "json" ]]; then
echo "[]"
else
echo "list_claude_agents: ninguna sesion valida encontrada (PIDs huerfanos, reciclados, o sin JSON)."
fi
return 0
fi
# -----------------------------------------------------------------------
# Salida JSON.
# -----------------------------------------------------------------------
if [[ "$output" == "json" ]]; then
# Delegar el escaping correcto de strings (cwd con espacios, etc.) a python3.
# El codigo python va en -c y los datos por stdin (TSV), para no colisionar
# el heredoc con el stdin del pipe.
local i
{
for ((i = 0; i < total; i++)); do
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"${a_pid[$i]}" \
"${a_sid[$i]}" \
"${a_cwd[$i]}" \
"${a_status[$i]}" \
"${a_etime[$i]}" \
"${a_kitty[$i]}" \
"${a_self[$i]}"
done
} | python3 -c '
import json, sys
out = []
for line in sys.stdin:
line = line.rstrip("\n")
if not line:
continue
pid, sid, cwd, status, etime, kitty, self_ = line.split("\t")
out.append({
"pid": int(pid) if pid.isdigit() else pid,
"session_id": sid,
"cwd": cwd,
"status": status,
"etime": etime,
"kitty_pid": (int(kitty) if kitty.isdigit() else (kitty or None)),
"self": (self_ == "true"),
})
print(json.dumps(out, indent=2))
'
return 0
fi
# -----------------------------------------------------------------------
# Salida tabla legible.
# -----------------------------------------------------------------------
echo "list_claude_agents — claudes vivos: ${total}"
echo
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
"PID" "STATUS" "ETIME" "KITTY" "SELF" "SESSION_ID" "CWD"
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
"--------" "-------" "------------" "---------" "------" \
"--------------------------------------" "---"
local i
for ((i = 0; i < total; i++)); do
local self_mark=""
[[ "${a_self[$i]}" == "true" ]] && self_mark="SELF"
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
"${a_pid[$i]}" \
"${a_status[$i]}" \
"${a_etime[$i]}" \
"${a_kitty[$i]:-(none)}" \
"${self_mark:--}" \
"${a_sid[$i]}" \
"${a_cwd[$i]}"
done
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
list_claude_agents "$@"
fi
@@ -0,0 +1,64 @@
---
name: reboot_all_claudes
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])"
description: "Cierra todas las terminales kitty con una sesion de Claude Code corriendo y las relanza retomando la misma sesion (claude --resume <sessionId>). Mapea cada PID vivo a su ~/.claude/sessions/<PID>.json para sacar sessionId, cwd y la ventana kitty. DRY-RUN por defecto; --go ejecuta de verdad de forma desacoplada."
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--go"
desc: "Ejecuta de verdad: mata las ventanas kitty y relanza las sesiones (detached). Alias --yes. Sin esto es dry-run."
- name: "--yes"
desc: "Alias de --go."
- name: "--resume-mode <resume|continue|none>"
desc: "Estrategia de reanudacion. resume (default): claude --resume <sessionId>. continue: claude --continue. none: sesion nueva en el mismo cwd."
- name: "--exclude-current"
desc: "No cierra ni relanza la terminal desde la que se invoca. Detecta el claude propio subiendo por la cadena de PPIDs hasta hallar un ancestro con comm=claude."
- name: "--only-idle"
desc: "Omite las sesiones con status busy (no pierde el turno en vuelo). Por defecto se incluyen todas y el dry-run avisa cuales estan busy."
- name: "-h|--help"
desc: "Muestra el uso y termina."
output: "Imprime una tabla del plan (PID, KITTY_PID, status, accion, sessionId, cwd) y el comando claude exacto por sesion. En dry-run no toca nada. Con --go lanza un script desacoplado en /tmp que cierra ventanas y relanza. Exit 0 normal; exit 2 si flags invalidos."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/reboot_all_claudes.sh"
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart; anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat; KITTY_PID del environ -> ventana a cerrar con SIGTERM; cmdline -> flags conservados (sin argv0 ni resume previos). El relanzamiento usa setsid kitty --directory <cwd> zsh -ic 'claude ...; exec zsh'. Como la propia terminal es una victima, el plan --go se escribe a /tmp y se lanza con setsid para sobrevivir al cierre del padre."
---
## Ejemplo
```bash
# Dry-run (default seguro): ver el plan sin tocar nada.
reboot_all_claudes
# Reiniciar de verdad todas las sesiones MENOS la terminal actual.
reboot_all_claudes --go --exclude-current
# Reiniciar solo las sesiones idle (no perder turnos en vuelo), de verdad.
reboot_all_claudes --go --only-idle
# Arrancar sesiones nuevas (sin reanudar la conversacion) en cada cwd.
reboot_all_claudes --go --resume-mode none
```
## Cuando usarla
Tras actualizar Claude Code (para que todas las sesiones corran la version nueva), o cuando varias sesiones se cuelgan y quieres reiniciarlas todas de golpe retomando exactamente la conversacion donde estaba cada una. Lanza siempre primero sin flags para revisar el plan; luego repite con `--go`.
## Gotchas
- **Es impura y se auto-mata.** La terminal desde la que la invocas suele ser una de las victimas; por eso el modo `--go` escribe un script a `/tmp/reboot_all_claudes.<pid>.<ts>.sh` y lo lanza con `setsid` para que el reparenting a init garantice los relanzamientos aunque el padre muera. Usa `--exclude-current` si quieres conservar la terminal actual.
- **Sesiones `busy` pierden el turno en vuelo.** Por defecto se reinician igual y el dry-run lo avisa explicitamente. Al reanudar con `--resume` se recupera hasta el ultimo mensaje completo guardado en el `.jsonl`. Usa `--only-idle` para no tocarlas.
- **Depende de `~/.claude/sessions/<PID>.json`** (formato de Claude Code 2.1.x). Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
- **Asume kitty como terminal.** Si un claude corre fuera de kitty (sin `KITTY_PID` en el environ, p.ej. terminal integrado de un editor), el fallback mata directamente el PID de claude y abre una kitty nueva en su `cwd`.
- **Anti-PID-reciclado:** valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide (o el JSON no existe, o `kill -0` falla) la sesion se omite como huerfana.
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
# reboot_all_claudes — Cierra todas las terminales con una sesion de Claude Code
# corriendo y las relanza retomando exactamente la sesion que tenian
# (claude --resume <sessionId>). Por defecto es DRY-RUN: imprime el plan sin
# tocar nada. Usar --go para ejecutarlo de verdad.
#
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito.
# - KITTY_PID del environ del proceso -> ventana kitty a cerrar.
# - cmdline del proceso -> flags originales a conservar (sin argv0 ni resume previos).
# - Relanzamiento detached (setsid) para sobrevivir al cierre de la propia terminal.
set -euo pipefail
IFS=$' \t\n'
reboot_all_claudes() {
local mode="dry" # dry | go
local resume_mode="resume" # resume | continue | none
local exclude_current=0
local only_idle=0
# -----------------------------------------------------------------------
# Parseo de argumentos
# -----------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--go|--yes)
mode="go"
;;
--resume-mode)
shift
resume_mode="${1:-}"
case "$resume_mode" in
resume|continue|none) ;;
*)
echo "reboot_all_claudes: --resume-mode invalido: '$resume_mode' (usa resume|continue|none)" >&2
return 2
;;
esac
;;
--exclude-current)
exclude_current=1
;;
--only-idle)
only_idle=1
;;
-h|--help)
cat <<'USAGE'
Uso: reboot_all_claudes [opciones]
Cierra todas las terminales con una sesion de Claude Code corriendo y las
relanza retomando la misma sesion (claude --resume <sessionId>).
Por defecto es DRY-RUN (accion destructiva => default seguro): imprime el plan
y NO mata ni relanza nada.
Opciones:
--go, --yes Ejecuta de verdad (kills + relanzamientos detached).
--resume-mode <modo> resume (default) | continue | none.
resume -> claude --resume <sessionId>
continue -> claude --continue
none -> claude (sesion nueva en el mismo cwd)
--exclude-current No cierra ni relanza la terminal desde la que se invoca.
--only-idle Omite sesiones con status busy (no pierde turnos en vuelo).
-h, --help Muestra esta ayuda.
Ejemplos:
reboot_all_claudes # dry-run, ve el plan
reboot_all_claudes --go --exclude-current # reinicia todas menos esta terminal
USAGE
return 0
;;
*)
echo "reboot_all_claudes: opcion desconocida: '$1' (usa -h)" >&2
return 2
;;
esac
shift
done
# -----------------------------------------------------------------------
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
# -----------------------------------------------------------------------
local current_claude_pid=""
if [[ "$exclude_current" -eq 1 ]]; then
local walk="$$"
local guard=0
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
local comm=""
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
if [[ "$comm" == "claude" ]]; then
current_claude_pid="$walk"
break
fi
# campo 4 de /proc/<pid>/stat es el PPID
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
guard=$((guard + 1))
[[ "$guard" -gt 64 ]] && break
done
fi
# -----------------------------------------------------------------------
# Recolectar las sesiones vivas y validarlas.
# -----------------------------------------------------------------------
local sessions_dir="$HOME/.claude/sessions"
local pids=""
pids="$(pgrep -x claude 2>/dev/null || true)"
if [[ -z "$pids" ]]; then
echo "reboot_all_claudes: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
return 0
fi
# Arrays paralelos con el plan validado.
local -a plan_pid plan_kitty plan_status plan_cwd plan_sid plan_cmd plan_skip plan_skipreason
local pid
for pid in $pids; do
# Validacion 1: el proceso debe seguir vivo.
if ! kill -0 "$pid" 2>/dev/null; then
continue
fi
# Validacion 2: debe existir su JSON de sesion.
local json="$sessions_dir/$pid.json"
if [[ ! -f "$json" ]]; then
continue
fi
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
# Salida: lineas "clave=valor" en orden fijo.
local parsed=""
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
import json, sys
try:
with open(sys.argv[1]) as fh:
d = json.load(fh)
except Exception:
sys.exit(0)
print("sessionId=" + str(d.get("sessionId", "")))
print("cwd=" + str(d.get("cwd", "")))
print("status=" + str(d.get("status", "")))
print("procStart=" + str(d.get("procStart", "")))
PY
)"
[[ -z "$parsed" ]] && continue
local sid cwd status proc_start_json
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
[[ -z "$sid" ]] && continue
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
# con el campo 22 de /proc/<PID>/stat.
local proc_start_real=""
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
# JSON huerfano de un PID reciclado: omitir.
continue
fi
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
local kitty_pid=""
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
# Flags originales: leer cmdline, descartar argv0 (claude) y cualquier
# flag de resume/continue previo para no duplicarlos.
local raw_cmd=""
raw_cmd="$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true)"
local -a kept_flags=()
local first=1 tok skipnext=0
while IFS= read -r tok; do
[[ -z "$tok" ]] && continue
if [[ "$first" -eq 1 ]]; then
# argv0 (la ruta o nombre de claude) — descartar.
first=0
continue
fi
if [[ "$skipnext" -eq 1 ]]; then
skipnext=0
continue
fi
case "$tok" in
--resume|--continue|-r|-c)
# Resume/continue previos: omitir (y su posible valor para --resume).
if [[ "$tok" == "--resume" || "$tok" == "-r" ]]; then
skipnext=1
fi
continue
;;
esac
kept_flags+=("$tok")
done <<< "$raw_cmd"
# Construir la estrategia de resume.
local -a launch_args=()
case "$resume_mode" in
resume) launch_args=("--resume" "$sid") ;;
continue) launch_args=("--continue") ;;
none) launch_args=() ;;
esac
launch_args+=("${kept_flags[@]}")
# Comando claude final (para mostrar y ejecutar).
local claude_cmd="claude"
local a
for a in "${launch_args[@]}"; do
claude_cmd+=" $(printf '%q' "$a")"
done
# Decidir si se omite esta sesion del plan.
local skip=0 skipreason=""
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
skip=1
skipreason="terminal actual (--exclude-current)"
elif [[ "$only_idle" -eq 1 && "$status" == "busy" ]]; then
skip=1
skipreason="busy (--only-idle)"
fi
plan_pid+=("$pid")
plan_kitty+=("${kitty_pid:-}")
plan_status+=("${status:-?}")
plan_cwd+=("${cwd:-?}")
plan_sid+=("$sid")
plan_cmd+=("$claude_cmd")
plan_skip+=("$skip")
plan_skipreason+=("$skipreason")
done
local total="${#plan_pid[@]}"
if [[ "$total" -eq 0 ]]; then
echo "reboot_all_claudes: ninguna sesion valida encontrada (todos los PIDs eran huerfanos o reciclados)."
return 0
fi
# -----------------------------------------------------------------------
# Imprimir el plan (siempre, tanto en dry-run como en --go).
# -----------------------------------------------------------------------
echo "reboot_all_claudes — modo: ${mode} resume: ${resume_mode} sesiones: ${total}"
echo
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "PID" "KITTY" "STATUS" "ACCION" "SESSION_ID" "CWD"
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "--------" "---------" "-------" "------" "--------------------------------------" "---"
local i busy_count=0 act_count=0
for ((i = 0; i < total; i++)); do
local accion="reinic"
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
accion="OMITE"
else
act_count=$((act_count + 1))
fi
[[ "${plan_status[$i]}" == "busy" ]] && busy_count=$((busy_count + 1))
printf '%-8s %-9s %-7s %-6s %-38s %s\n' \
"${plan_pid[$i]}" \
"${plan_kitty[$i]:-(none)}" \
"${plan_status[$i]}" \
"$accion" \
"${plan_sid[$i]}" \
"${plan_cwd[$i]}"
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
echo " -> omitida: ${plan_skipreason[$i]}"
else
echo " -> ${plan_cmd[$i]}"
fi
done
echo
# Aviso explicito de sesiones busy que SI se van a reiniciar.
if [[ "$only_idle" -eq 0 ]]; then
local warned=0
for ((i = 0; i < total; i++)); do
if [[ "${plan_skip[$i]}" -eq 0 && "${plan_status[$i]}" == "busy" ]]; then
if [[ "$warned" -eq 0 ]]; then
echo "AVISO: las siguientes sesiones estan BUSY y se reiniciaran; perderan el turno en vuelo"
echo " (al reanudar con --resume se recupera hasta el ultimo mensaje completo guardado):"
warned=1
fi
echo " - PID ${plan_pid[$i]} cwd=${plan_cwd[$i]}"
fi
done
[[ "$warned" -eq 1 ]] && echo
fi
# -----------------------------------------------------------------------
# DRY-RUN: parar aqui.
# -----------------------------------------------------------------------
if [[ "$mode" == "dry" ]]; then
echo "DRY-RUN: no se ha matado ni relanzado nada."
echo "Para ejecutar de verdad: reboot_all_claudes --go"
return 0
fi
if [[ "$act_count" -eq 0 ]]; then
echo "reboot_all_claudes: nada que hacer (todas las sesiones quedaron omitidas)."
return 0
fi
# -----------------------------------------------------------------------
# MODO --go: construir un script desacoplado que mata las ventanas y
# relanza las sesiones. Se ejecuta con setsid para que sobreviva al cierre
# de la propia terminal (que es una de las victimas).
# -----------------------------------------------------------------------
local ts script log
ts="$(date +%s)"
script="/tmp/reboot_all_claudes.$$.$ts.sh"
log="/tmp/reboot_all_claudes.$ts.log"
{
echo '#!/usr/bin/env bash'
echo 'set -uo pipefail'
echo '# Dar tiempo a que la terminal padre devuelva el control antes de matar.'
echo 'sleep 1'
echo
for ((i = 0; i < total; i++)); do
[[ "${plan_skip[$i]}" -eq 1 ]] && continue
local kp="${plan_kitty[$i]}"
local cp="${plan_pid[$i]}"
local cwd="${plan_cwd[$i]}"
local cmd="${plan_cmd[$i]}"
echo "# --- sesion PID ${cp} (kitty ${kp:-none}) ---"
if [[ -n "$kp" ]]; then
# Cerrar la ventana kitty limpia con SIGTERM.
echo "kill $(printf '%q' "$kp") 2>/dev/null || true"
else
# Sin kitty: matar el propio claude.
echo "kill $(printf '%q' "$cp") 2>/dev/null || true"
fi
# Relanzar en una kitty nueva, detached, en el cwd correcto.
# zsh -ic '...; exec zsh' replica el patron del usuario: al salir de
# claude queda una shell interactiva viva.
printf 'setsid kitty --directory %q zsh -ic %q </dev/null >/dev/null 2>&1 &\n' \
"$cwd" "${cmd}; exec zsh"
echo
done
echo 'exit 0'
} > "$script"
chmod +x "$script"
echo "reboot_all_claudes: lanzando plan desacoplado -> $script (log: $log)"
setsid bash "$script" </dev/null >>"$log" 2>&1 &
disown 2>/dev/null || true
echo "reboot_all_claudes: hecho. Las terminales se cerraran y reabriran en ~1s."
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
reboot_all_claudes "$@"
fi
@@ -3,12 +3,12 @@ name: write_mcp_jupyter_config
kind: function
lang: bash
domain: infra
version: "1.1.0"
version: "1.2.0"
purity: impure
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
description: "Genera o actualiza .mcp.json con la config de jupyter-mcp-server apuntando al console-script del venv local (transport stdio + flags --jupyter-url/--jupyter-token). Merge con jq reemplazando la entrada jupyter entera."
description: "Genera o actualiza .mcp.json para un analisis Jupyter. La entrada jupyter usa el wrapper jupyter_mcp_serve.sh con env overrides (venv, root y puerto del analisis), de modo que el MCP arranca su propio Jupyter con el venv del analisis. Merge con jq reemplazando la entrada jupyter entera."
tags: [mcp, jupyter, config, setup, infra, notebook]
uses_functions: []
uses_functions: [jupyter_mcp_serve_bash_infra]
uses_types: []
returns: []
returns_optional: false
@@ -16,9 +16,9 @@ error_type: "error_go_core"
imports: []
params:
- name: project_dir
desc: "directorio del proyecto Jupyter (default: directorio actual)"
desc: "directorio del proyecto/analisis Jupyter (default: directorio actual)"
- name: port
desc: "puerto Jupyter (default: detectado automáticamente)"
desc: "puerto Jupyter del analisis (default: 8888)"
output: "ruta del archivo .mcp.json generado o actualizado"
tested: false
tests: []
@@ -33,25 +33,33 @@ source write_mcp_jupyter_config.sh
path=$(write_mcp_jupyter_config $HOME/fn_registry/analysis/finanzas 8890)
echo "Config MCP en: $path"
# Genera .mcp.json con:
# "command": ".../.venv/bin/jupyter-mcp-server"
# "args": ["--transport","stdio","--jupyter-url","http://localhost:8890","--jupyter-token",""]
# "command": "bash"
# "args": [".../bash/functions/infra/jupyter_mcp_serve.sh"]
# "env": {
# "JUPYTER_MCP_VENV": ".../analysis/finanzas/.venv",
# "JUPYTER_MCP_ROOT": ".../analysis/finanzas",
# "JUPYTER_MCP_PORT": "8890",
# "JUPYTER_MCP_TOKEN": ""
# }
```
## Cuando usarla
- Al crear un analysis Jupyter nuevo (la usa el pipeline `init_jupyter_analysis`).
- Tras mover/recrear un venv y necesitar regenerar el `.mcp.json` del analysis.
- Para reparar un `.mcp.json` con el comando viejo roto (`python -m jupyter_mcp_server.server`).
- Para reparar un `.mcp.json` con el comando viejo (console-script directo que no arranca Jupyter, o `python -m jupyter_mcp_server.server`).
## Gotchas
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; el proceso importa y sale 0 y el MCP nunca arranca. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`), expuesta como console-script `jupyter-mcp-server`. Sin subcomando arranca en stdio por defecto.
- **No usa env vars** `SERVER_URL`/`TOKEN`. La CLI lee flags `--jupyter-url` / `--jupyter-token` (cubren document + runtime). Configs viejas con bloque `env` quedan inertes.
- **Tolera Jupyter apagado al boot**: el MCP responde `initialize` tras un connect-timeout (~10s) y sirve igual. Arrancar Jupyter despues en `:port` y los tools se enganchan. No hace falta reiniciar Claude por tener Jupyter caido al inicio.
- **Requiere `jupyter-mcp-server` instalado en el venv**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
- **Path atado al venv del analysis**: si borras el analysis, ese `.mcp.json` apunta a un binario inexistente. Para un MCP jupyter global e independiente, el `.mcp.json` raiz de `fn_registry` usa el binario del venv canonico `python/.venv/bin/jupyter-mcp-server` (sobrevive el borrado de cualquier analysis).
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas.
- **Usa el wrapper, no el console-script directo**: el `.mcp.json` apunta a `jupyter_mcp_serve.sh` (ver `jupyter_mcp_serve_bash_infra`), que arranca (o reusa) el Jupyter del analisis con su venv antes de exec del MCP. Con el console-script directo (`jupyter-mcp-server --jupyter-url ...`) el MCP solo se CONECTA: si el server no esta levantado no hay kernel y las operaciones sobre notebooks fallan. Con el wrapper basta abrir Claude desde el analisis — no hace falta lanzar `run-jupyter-lab.sh` aparte.
- **El venv del kernel es el del analisis** (`JUPYTER_MCP_VENV`), no `python/.venv` del repo. Asi cada analisis ejecuta con sus propias dependencias sin contaminar el venv canonico. Este fix nacio de un caso real (analisis `nats`): trabajar desde la raiz de `fn_registry` cargaba el MCP global (8899, venv `python/.venv`) que no tenia `nats-py`.
- **Reuso por puerto**: si ya hay un Jupyter escuchando en `JUPYTER_MCP_PORT` (p.ej. lanzado por `run-jupyter-lab.sh`, que es colaborativo), el wrapper lo reusa en vez de arrancar otro. Si no hay ninguno, el wrapper levanta uno propio (sin `--collaborative`, suficiente para el MCP). Para colaboracion humana en tiempo real, lanzar `run-jupyter-lab.sh` antes.
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; importa y sale 0, el MCP nunca arranca. El entrypoint real es el console-script `jupyter-mcp-server`, que el wrapper localiza dentro del venv del analisis.
- **Requiere `jupyter-mcp-server` instalado en el venv del analisis**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
- **Localiza el wrapper subiendo directorios** desde `project_dir` (hasta 8 niveles) buscando `bash/functions/infra/jupyter_mcp_serve.sh`; si no lo encuentra, usa `FN_REGISTRY_ROOT`. Aborta si no aparece por ninguna via.
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas (p.ej. el bloque `args` del console-script directo).
## Capability growth log
- v1.2.0 (2026-06-03) — el `.mcp.json` generado usa el wrapper `jupyter_mcp_serve.sh` con env overrides (`JUPYTER_MCP_VENV/ROOT/PORT/TOKEN`) en vez del console-script directo. Garantiza que el MCP arranca su propio Jupyter con el venv del analisis (antes solo conectaba y usaba el venv equivocado si se abria Claude desde la raiz del repo). Declara dependencia `jupyter_mcp_serve_bash_infra`.
- v1.1.0 (2026-05-28) — fix comando roto: console-script `jupyter-mcp-server` + flags stdio en vez de `python -m ...server` + env vars. Merge `+` para reemplazar entrada entera. Tag `notebook`.
@@ -1,21 +1,32 @@
# write_mcp_jupyter_config
# -------------------------
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
# Usa el console-script `jupyter-mcp-server` del venv local con transport stdio
# y los flags --jupyter-url / --jupyter-token (NO env vars, NO `-m ...server`).
# Hace merge si ya existe .mcp.json (requiere jq).
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server para un
# analisis/proyecto. La entrada `jupyter` usa el wrapper `jupyter_mcp_serve.sh`
# (no el console-script directo), de modo que el MCP SIEMPRE tiene servidor: el
# wrapper arranca (o reusa) un Jupyter Lab en el puerto indicado usando el venv
# del propio analisis y lo engancha al MCP por stdio.
#
# Por que el wrapper y no el console-script directo: el console-script
# `jupyter-mcp-server --jupyter-url http://localhost:PORT` solo se CONECTA, no
# arranca Jupyter. Si el server no esta levantado, el MCP responde `initialize`
# pero no hay kernel y toda operacion sobre notebooks falla. El wrapper levanta el
# server con el venv correcto (JUPYTER_MCP_VENV) antes de exec del MCP, asi que
# abrir Claude desde el analisis basta — no hace falta lanzar run-jupyter-lab.sh
# aparte. Si ya hay un Jupyter en ese puerto (p.ej. run-jupyter-lab.sh), lo reusa.
#
# Env overrides que se inyectan al wrapper (ver jupyter_mcp_serve.sh):
# JUPYTER_MCP_VENV venv del analisis (su .venv, con jupyter + jupyter-mcp-server)
# JUPYTER_MCP_ROOT root de notebooks = directorio del analisis
# JUPYTER_MCP_PORT puerto del Jupyter gestionado
# JUPYTER_MCP_TOKEN token (vacio: solo escucha en 127.0.0.1)
#
# GOTCHA (2026-05-28): `python -m jupyter_mcp_server.server` NO arranca nada —
# server.py no tiene bloque __main__, asi que el proceso importa y sale 0 y el
# MCP nunca levanta. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`,
# expuesta como console-script `jupyter-mcp-server`), que sin subcomando arranca
# en stdio por defecto. La config tampoco lee SERVER_URL/TOKEN: usa los flags
# --jupyter-url / --jupyter-token. El MCP tolera que Jupyter este apagado al
# arrancar (responde `initialize` tras un connect-timeout ~10s y sirve igual).
# server.py no tiene bloque __main__. El entrypoint real es el console-script
# `jupyter-mcp-server` (que el wrapper localiza dentro del venv del analisis).
#
# USO (sourced):
# source write_mcp_jupyter_config.sh
# write_mcp_jupyter_config /path/to/project 8888
# write_mcp_jupyter_config /path/to/analysis 8890
write_mcp_jupyter_config() {
local project_dir="${1:-.}"
@@ -31,23 +42,47 @@ write_mcp_jupyter_config() {
return 1
fi
# Verificar que el console-script esta instalado
# Verificar que el console-script esta instalado en el venv del analisis
if [ ! -x "$mcp_bin" ]; then
echo "write_mcp_jupyter_config: jupyter-mcp-server no instalado en el venv (${mcp_bin}). Instala con: uv pip install jupyter-mcp-server" >&2
return 1
fi
# Localizar el wrapper jupyter_mcp_serve.sh subiendo desde el directorio del
# analisis hasta la raiz del repo. Fallback a FN_REGISTRY_ROOT.
local wrapper="" d="$abs_project"
local i
for i in 1 2 3 4 5 6 7 8; do
if [ -f "$d/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
wrapper="$d/bash/functions/infra/jupyter_mcp_serve.sh"
break
fi
d="$(dirname "$d")"
[ "$d" = "/" ] && break
done
if [ -z "$wrapper" ] && [ -n "${FN_REGISTRY_ROOT:-}" ] && [ -f "${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
wrapper="${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh"
fi
if [ -z "$wrapper" ]; then
echo "write_mcp_jupyter_config: no encuentro bash/functions/infra/jupyter_mcp_serve.sh subiendo desde ${abs_project} ni en FN_REGISTRY_ROOT" >&2
return 1
fi
local new_config
new_config=$(cat << EOF
{
"mcpServers": {
"jupyter": {
"command": "${mcp_bin}",
"command": "bash",
"args": [
"--transport", "stdio",
"--jupyter-url", "http://localhost:${port}",
"--jupyter-token", ""
]
"${wrapper}"
],
"env": {
"JUPYTER_MCP_VENV": "${abs_project}/.venv",
"JUPYTER_MCP_ROOT": "${abs_project}",
"JUPYTER_MCP_PORT": "${port}",
"JUPYTER_MCP_TOKEN": ""
}
}
}
}
@@ -57,7 +92,7 @@ EOF
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
# Merge conservando otros servidores MCP. Usa `+` (shallow) en el mapa de
# servidores para REEMPLAZAR la entrada `jupyter` entera — `*` (deep) dejaba
# keys huerfanas de configs viejas (ej. bloque `env` obsoleto).
# keys huerfanas de configs viejas (ej. flags `args` obsoletos).
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
mv "${mcp_file}.tmp" "$mcp_file"
+10 -3
View File
@@ -3,14 +3,15 @@ name: full_git_pull
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "full_git_pull() -> stdout: tabla resumen"
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index y ejecuta fn sync."
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index, ejecuta fn sync y reclona los sub-repos hijos faltantes de cada project (apps/analysis) via clone_project_subrepos."
tags: [git, pull, sync, registry, pipeline, pendiente-usar]
uses_functions:
- discover_git_repos_bash_infra
- git_pull_with_stash_bash_infra
- clone_project_subrepos_bash_pipelines
- pass_get_bash_infra
uses_types: []
returns: []
@@ -51,4 +52,10 @@ bash bash/functions/pipelines/full_git_pull.sh
## Notas
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. No clona repos faltantes: cada PC tiene el subset que le interesa (clonar manualmente si se necesita uno nuevo). Modo completamente no-interactivo.
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. Modo completamente no-interactivo.
Desde v1.1.0 SI reclona los sub-repos hijos faltantes de cada project: tras `fn sync` (que trae a `registry.db` las filas de apps/analysis de todos los PCs), itera los projects y llama `clone_project_subrepos` para traer al disco los hijos que falten, re-indexando si clono alguno. `registry.db` actua como manifest de sub-repos, asi que clonar el project paraguas + `/full-git-pull` reconstruye su arbol entero sin adivinar nombres. Los repos sueltos (sin project) siguen sin auto-clonarse: cada PC tiene el subset que le interesa.
## Capability growth log
- v1.1.0 (2026-06-10) — anade el paso 6: reclonado de sub-repos hijos de cada project via `clone_project_subrepos` tras `fn sync`, con re-index si clona alguno. Permite reconstruir el arbol completo de un project en un PC nuevo (issue 0171).
+39
View File
@@ -149,6 +149,42 @@ full_git_pull() {
fi
fi
# --- Paso 6: Reclonar sub-repos hijos de cada project (issue 0171) ---
# Tras fn sync, registry.db contiene las filas apps/analysis de TODOS los PCs.
# clone_project_subrepos clona en este disco los hijos que falten (skip si ya
# existen). Asi, clonar el project paraguas y correr /full-git-pull reconstruye
# su arbol entero sin adivinar nombres de sub-repos: registry.db ES el manifest.
echo "" >&2
echo "[6/6] Reclonando sub-repos de projects..." >&2
local reclone_summary=" [skip] sin projects o registry.db"
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
export FN_REGISTRY_ROOT="$registry_root"
export GITEA_URL="${GITEA_URL:-$(pass_get agentes/gitea-url | head -n1 2>/dev/null || true)}"
local clone_script="$SCRIPT_DIR/clone_project_subrepos.sh"
local any_cloned=0
if [[ -f "$clone_script" ]]; then
while IFS= read -r proj_id; do
[[ -z "$proj_id" ]] && continue
local clone_out
clone_out=$(bash "$clone_script" "$proj_id" 2>&1 || true)
if echo "$clone_out" | grep -q '\[cloned\]'; then
any_cloned=1
echo " $proj_id: nuevos sub-repos clonados" >&2
fi
done < <(sqlite3 "$registry_root/registry.db" "SELECT id FROM projects;" 2>/dev/null)
if [[ "$any_cloned" -eq 1 ]]; then
echo " re-index tras clonado..." >&2
[[ -x "$fn_bin" ]] && CGO_ENABLED=1 "$fn_bin" index >/dev/null 2>&1 || true
reclone_summary=" OK: nuevos sub-repos clonados + re-index"
else
reclone_summary=" OK: nada que clonar (todo presente)"
fi
else
reclone_summary=" [skip] clone_project_subrepos.sh no encontrado"
fi
fi
echo " $reclone_summary" >&2
# --- Resumen ---
echo ""
echo "===== RESUMEN full_git_pull ====="
@@ -171,6 +207,9 @@ full_git_pull() {
echo ""
echo "fn sync:"
echo "$sync_summary"
echo ""
echo "Reclonado sub-repos de projects:"
echo "$reclone_summary"
if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then
echo ""
+7 -2
View File
@@ -3,10 +3,10 @@ name: full_git_push
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen"
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses sin .git via ensure_repo_synced, auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses Y projects paraguas sin .git via ensure_repo_synced (asegurando el .gitignore canonico del project antes), auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
tags: [git, push, sync, registry, pipeline, pendiente-usar]
uses_functions:
- discover_git_repos_bash_infra
@@ -14,6 +14,7 @@ uses_functions:
- git_auto_commit_dirty_bash_infra
- git_push_if_ahead_bash_infra
- ensure_repo_synced_bash_infra
- ensure_project_gitignore_bash_infra
- pass_get_bash_infra
uses_types: []
returns: []
@@ -62,3 +63,7 @@ bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
## Notas
El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo.
## Capability growth log
- v1.1.0 (2026-06-10) — auto-inicializa tambien los projects paraguas (`projects/<p>/`) sin repo Gitea, no solo apps/analyses. Antes de pushear cada project asegura su `.gitignore` canonico via `ensure_project_gitignore` para no trackear el contenido de los sub-repos hijos. Cierra el agujero por el que projects como aurgi/obsidian/osint vivian solo en disco y se perdian al borrar el PC (issue 0171).
+32 -20
View File
@@ -13,6 +13,7 @@ source "$INFRA_DIR/git_auto_commit_dirty.sh"
source "$INFRA_DIR/git_push_if_ahead.sh"
source "$INFRA_DIR/pass_get.sh"
source "$INFRA_DIR/ensure_repo_synced.sh"
source "$INFRA_DIR/ensure_project_gitignore.sh"
source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
full_git_push() {
@@ -65,6 +66,32 @@ full_git_push() {
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
echo " [warn] fallo inicializando $d" >&2
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
# Paso 1c: Auto-inicializar los PROJECTS paraguas sin .git (issue 0171).
# El directorio projects/<p>/ versiona SOLO las docs de nivel-project
# (project.md, vault.yaml, CONVENTIONS.md, tools/...). Sus hijos apps/* y
# analysis/* son sub-repos Gitea independientes, excluidos por el .gitignore
# canonico que ensure_project_gitignore garantiza ANTES del push para no
# trackear su contenido (doble-tracking). Sin esto, un project sin repo
# (aurgi, obsidian, osint) vivia solo en disco y se perdia al borrar el PC.
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
while IFS= read -r proj_dir; do
[[ -z "$proj_dir" ]] && continue
local pd="$registry_root/$proj_dir"
[[ -d "$pd" ]] || continue
# Garantizar el .gitignore canonico ANTES de cualquier git add -A.
ensure_project_gitignore "$pd" || \
echo " [warn] no se pudo asegurar .gitignore de $pd" >&2
if [[ -d "$pd/.git" ]]; then
git -C "$pd" remote get-url origin >/dev/null 2>&1 && continue
echo " fix-remote: $pd (.git sin origin)" >&2
else
echo " auto-init project: $pd" >&2
fi
ensure_repo_synced "$pd" dataforge "$(basename "$pd")" master "chore: initial sync project" || \
echo " [warn] fallo inicializando project $pd" >&2
done < <(sqlite3 "$registry_root/registry.db" "SELECT CASE WHEN dir_path != '' THEN dir_path ELSE 'projects/'||id END FROM projects;" 2>/dev/null)
fi
else
echo " [warn] registry.db o sqlite3 no disponibles — omitiendo auto-init BD-driven" >&2
fi
@@ -72,28 +99,13 @@ full_git_push() {
echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2
fi
# Redescubrir repos tras posibles inicializaciones
# Redescubrir repos tras posibles inicializaciones.
# El repo de config de Claude (dataforge/repo_Claude, al que apuntan los
# symlinks de ~/.claude/) vive en fn_registry/external/repo_Claude, asi que
# discover_git_repos ya lo encuentra y pasa por scan-secrets/commit/push
# como un repo mas. No necesita tratamiento especial.
repos=$(discover_git_repos "$registry_root")
# --- Paso 1c: Incluir el repo de configuracion de Claude ---
# Los archivos de ~/.claude/ (settings.json, commands, skills, CLAUDE.md...)
# son symlinks a un repo git externo (dataforge/repo_Claude). Lo resolvemos
# de forma portable siguiendo el symlink de settings.json — sin hardcodear
# el path, que difiere entre PCs. Si resuelve a un repo git, lo anadimos a
# la lista para que pase por scan-secrets + auto-commit + push como los demas.
local claude_repo=""
if [[ -L "$HOME/.claude/settings.json" ]]; then
local _claude_settings_real
_claude_settings_real=$(readlink -f "$HOME/.claude/settings.json" 2>/dev/null || true)
if [[ -n "$_claude_settings_real" ]]; then
claude_repo=$(git -C "$(dirname "$_claude_settings_real")" rev-parse --show-toplevel 2>/dev/null || true)
fi
fi
if [[ -n "$claude_repo" && -d "$claude_repo/.git" ]]; then
echo "[1c] Incluyendo repo de config Claude: $claude_repo" >&2
repos="$repos"$'\n'"$claude_repo"
fi
# --- Paso 2: Escanear secrets ---
echo "" >&2
echo "[2/6] Escaneando secrets en dirty trees..." >&2
@@ -0,0 +1,67 @@
---
name: reset_chrome_profiles
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "reset_chrome_profiles --user-data-dir <dir> [--profile \"<dir>=<legible>\"]... [--backup-dir <dir>] [--base-port 9250] [--keep <ext_id>]... [--dry-run] [--yes]"
description: "Pipeline de reset destructivo de perfiles de Chromium: hace backup de los bookmarks de todos los perfiles, cierra el chromium que use ese user-data-dir, borra los perfiles (carpeta + Local State), los recrea (la managed policy reinstala la whitelist de extensiones uBlock + web_proxy), restaura los bookmarks y verifica que cada perfil quedó solo con la whitelist. DESTRUCTIVO: se pierden cookies, logins, historial y contraseñas; solo los bookmarks se preservan. Requiere --yes en modo real."
tags: [launcher, navegator, chromium, pipeline, profile, reset]
uses_functions:
- backup_chrome_bookmarks_bash_browser
- delete_chrome_profile_bash_browser
- create_chrome_profile_bash_browser
- restore_chrome_bookmarks_bash_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--user-data-dir <dir>"
desc: "Raíz del user-data-dir de Chromium cuyos perfiles se resetean (ej. ~/.config/chromium-cdp)."
- name: "--profile <dir=legible>"
desc: "Perfil a resetear, formato carpeta=nombre-legible (repetible). Default los 4 reales: Default=Work, Personal=Personal, 'Profile 1'=Aurgi, Automation=Automation."
- name: "--backup-dir <dir>"
desc: "Directorio donde se guardan los backups de bookmarks. Default ~/.local/share/web_scraping/bookmarks-backups."
- name: "--base-port <N>"
desc: "Puerto CDP base para recrear perfiles (cada perfil usa base+i). Default 9250."
- name: "--keep <ext_id>"
desc: "ID de extensión esperada tras el reset (repetible). Default uBlock Origin Lite + web_proxy toggle. Solo se usa en la verificación final."
- name: "--dry-run"
desc: "Previsualiza los 6 pasos sin tocar el sistema."
- name: "--yes"
desc: "Confirma la operación destructiva (obligatorio en modo real)."
output: "Ejecuta backup → cerrar chromium → delete → create → restore → verify. Emite el progreso de cada paso y un resumen. Sale 0 si todo OK y cada perfil quedó solo con la whitelist; != 0 si falla algún paso o la verificación detecta extensiones fuera de la whitelist."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/reset_chrome_profiles.sh"
---
## Ejemplo
```bash
# Previsualizar el reset de los 4 perfiles del chromium diario (no toca nada)
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --dry-run
# Reset real (destructivo): backup bookmarks, borrar+recrear los 4 perfiles, restaurar bookmarks
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --yes
# Reset de un solo perfil con nombre legible
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" \
--profile "Automation=Automation" --yes
```
## Cuando usarla
Cuando quieras dejar los perfiles de un Chromium **limpios desde cero** conservando solo la whitelist de extensiones (uBlock + la de captura del web_proxy) y preservando los bookmarks, pero descartando todo el resto del estado (cookies, logins, historial). Útil para volver a un estado conocido de scraping/captura o para limpiar perfiles contaminados. La managed policy de `/etc` ya fuerza la whitelist, así que los perfiles recreados nacen correctos.
## Gotchas
- **DESTRUCTIVO**: cookies, logins, historial y contraseñas de los perfiles se pierden de forma irreversible. Solo los bookmarks se preservan (backup + restore byte a byte). Por eso requiere `--yes` en modo real.
- **Cierra el chromium del user-data-dir indicado** (pkill por `--user-data-dir`), no cualquier chromium. Si tienes otro chromium con otro user-data-dir, no se toca.
- **Depende de la managed policy**: los perfiles recreados solo tendrán uBlock + web_proxy si la policy de `/etc/chromium/policies/managed/extensions.json` las fuerza (ver `apply_chromium_extension_policy_bash_browser`). Si la policy no está, los perfiles nacen sin extensiones.
- La verificación final comprueba las carpetas en `<profile>/Extensions/`; para una auditoría detallada (nombre, versión, enabled, fromPolicy) usar `list_chrome_profile_extensions_go_browser`.
- Lanzar chromium desde el Bash tool da exit-144; `create_chrome_profile` usa `systemd-run --user` internamente para evitarlo.
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# reset_chrome_profiles — Pipeline de reset destructivo de perfiles de Chromium.
#
# Compone funciones del registry para: hacer backup de los bookmarks de todos los perfiles,
# cerrar chromium, borrar los perfiles (carpeta + entradas en Local State), recrearlos
# (la managed policy reinstala la whitelist de extensiones: uBlock + web_proxy), restaurar
# los bookmarks y verificar que cada perfil quedó solo con la whitelist.
#
# DESTRUCTIVO: borra cookies, logins, historial y contraseñas de los perfiles. Solo los
# bookmarks se preservan (backup + restore). Requiere --yes en modo real (o --dry-run).
#
# Uso:
# reset_chrome_profiles --user-data-dir <dir>
# [--profile "<dir>=<legible>"]... [--backup-dir <dir>] [--base-port 9250]
# [--keep <ext_id>]... [--dry-run] [--yes]
#
# Defaults de --profile (los 4 perfiles reales): "Default=Work" "Personal=Personal"
# "Profile 1=Aurgi" "Automation=Automation".
# Default de --keep (whitelist esperada tras el reset): uBlock Origin Lite + web_proxy toggle.
reset_chrome_profiles() {
local _udd="" _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
local _base_port=9250 _dry_run=0 _yes=0
local -a _profiles=()
local -a _keep=()
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _udd="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--base-port) _base_port="$2"; shift 2 ;;
--keep) _keep+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
--yes) _yes=1; shift ;;
-h|--help)
grep '^#' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; return 0 ;;
*) echo "reset_chrome_profiles: argumento desconocido: $1" >&2; return 1 ;;
esac
done
if [[ -z "$_udd" ]]; then
echo "reset_chrome_profiles: --user-data-dir es obligatorio" >&2; return 1
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
_profiles=("Default=Work" "Personal=Personal" "Profile 1=Aurgi" "Automation=Automation")
fi
if [[ ${#_keep[@]} -eq 0 ]]; then
_keep=("ddkjiahejlhfcafbddmgiahcphecmpfh" "nanldmckabfghgdebblpfbdbhphhbnde")
fi
# Localizar las funciones del registry que componemos.
local _dir _root _browser
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_root="$(cd "$_dir/../../.." && pwd)"
_browser="$_root/bash/functions/browser"
local _f
for _f in backup_chrome_bookmarks restore_chrome_bookmarks delete_chrome_profile create_chrome_profile; do
if [[ ! -f "$_browser/$_f.sh" ]]; then
echo "reset_chrome_profiles: falta función $_f en $_browser" >&2; return 1
fi
# shellcheck disable=SC1090
source "$_browser/$_f.sh"
done
echo "=== reset_chrome_profiles ==="
echo " user-data-dir : $_udd"
echo " perfiles : ${_profiles[*]}"
echo " whitelist ext : ${_keep[*]}"
echo " backup-dir : $_backup_dir"
echo " modo : $([[ $_dry_run -eq 1 ]] && echo DRY-RUN || echo REAL)"
echo ""
# Confirmación obligatoria en modo real.
if [[ $_dry_run -eq 0 && $_yes -eq 0 ]]; then
echo "reset_chrome_profiles: operación DESTRUCTIVA (se pierden cookies/logins/historial)." >&2
echo " Repite con --yes para confirmar, o usa --dry-run para previsualizar." >&2
return 3
fi
# ── [1/6] Backup de bookmarks (solo lee; chromium puede estar abierto) ──────
echo "[1/6] Backup de bookmarks..."
local _bk_json _ts_dir
if [[ $_dry_run -eq 1 ]]; then
backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir" --dry-run
_ts_dir="<dry-run>"
else
_bk_json="$(backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir")" || {
echo "reset_chrome_profiles: backup falló" >&2; return 1; }
echo "$_bk_json"
_ts_dir="$(printf '%s' "$_bk_json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["backup_dir"]+"/"+d["ts"])')"
echo " backup en: $_ts_dir"
fi
echo ""
# ── [2/6] Cerrar chromium que tenga ESTE user-data-dir abierto ─────────────
echo "[2/6] Cerrando chromium con --user-data-dir=$_udd ..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: no se cierra nada)"
else
# Por-PID con comm=chromium (pgrep -x) para no auto-matchear grep/pgrep (el path del udd
# contiene la cadena "chromium").
local _p _kpids _i=0
_kpids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _kpids="$_kpids $_p"
done
if [[ -n "${_kpids// }" ]]; then
# shellcheck disable=SC2086
kill -TERM $_kpids 2>/dev/null || true
while :; do
_kpids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _kpids="$_kpids $_p"
done
[[ -z "${_kpids// }" ]] && break
_i=$((_i+1)); [[ $_i -ge 20 ]] && { kill -9 $_kpids 2>/dev/null || true; break; }
sleep 0.5
done
echo " chromium cerrado."
else
echo " (no había chromium con ese user-data-dir)"
fi
fi
echo ""
# ── [3/6] Borrar perfiles (carpeta + Local State) ──────────────────────────
echo "[3/6] Borrando perfiles..."
local _del_args=() _pair _pdir
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"
_del_args+=(--profile "$_pdir")
done
if [[ $_dry_run -eq 1 ]]; then
delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" --dry-run
else
delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" || {
echo "reset_chrome_profiles: delete falló" >&2; return 1; }
fi
echo ""
# ── [4/6] Recrear perfiles (la policy reinstala la whitelist al arrancar) ───
echo "[4/6] Recreando perfiles..."
local _idx=0 _name _port
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"; _name="${_pair#*=}"; _port=$((_base_port + _idx))
if [[ $_dry_run -eq 1 ]]; then
create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" --dry-run
else
create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" || {
echo "reset_chrome_profiles: create de '$_pdir' falló" >&2; return 1; }
fi
_idx=$((_idx+1))
done
echo ""
# ── [5/6] Restaurar bookmarks ──────────────────────────────────────────────
echo "[5/6] Restaurando bookmarks..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: restauraría desde el backup recién creado)"
else
restore_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_ts_dir" || {
echo "reset_chrome_profiles: restore falló (continúo a verify)" >&2; }
fi
echo ""
# ── [6/6] Verificar extensiones por perfil (carpetas en Extensions/) ───────
echo "[6/6] Verificando extensiones (esperado: solo la whitelist)..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: verificaría que cada perfil tiene solo ${_keep[*]})"
echo ""
echo "reset_chrome_profiles: DRY-RUN completado, nada se modificó."
return 0
fi
local _ok=1
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"
local _extdir="$_udd/$_pdir/Extensions"
local -a _present=()
if [[ -d "$_extdir" ]]; then
local _e
for _e in "$_extdir"/*/; do
_e="$(basename "$_e")"
[[ "$_e" == "Temp" || "$_e" == "*" ]] && continue
_present+=("$_e")
done
fi
# Comprobar que todo lo presente está en la whitelist.
local _extra=()
local _id _found
for _id in "${_present[@]}"; do
_found=0
local _k
for _k in "${_keep[@]}"; do [[ "$_id" == "$_k" ]] && _found=1; done
[[ $_found -eq 0 ]] && _extra+=("$_id")
done
if [[ ${#_extra[@]} -gt 0 ]]; then
echo "$_pdir: extensiones fuera de whitelist: ${_extra[*]}"
_ok=0
else
echo "$_pdir: ${_present[*]:-<vacío, aún sin arrancar>}"
fi
done
echo ""
if [[ $_ok -eq 1 ]]; then
echo "reset_chrome_profiles: OK — perfiles recreados, bookmarks restaurados, solo la whitelist presente."
return 0
else
echo "reset_chrome_profiles: verificación con avisos (revisar arriba)." >&2
return 1
fi
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
reset_chrome_profiles "$@"
fi
@@ -0,0 +1,65 @@
---
name: close_onlyoffice_instance
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "close_onlyoffice_instance(instance: string = demo, [--purge]) -> json"
description: "Termina el/los proceso(s) DesktopEditors de una INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su HOME=/tmp/oo_<instance> leido de /proc/<pid>/environ — asi NUNCA mata la instancia personal del usuario, solo la aislada. Envia SIGTERM, espera ~3s por evento (read -t, sin sleep foreground) y SIGKILL a los que sigan vivos. Con el flag --purge borra ademas los directorios del slot (/tmp/oo_<instance>*). Imprime JSON con instance, killed_pids (array), purged y status (closed|not_running)."
tags: [onlyoffice, desktop, x11, shell]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: instance
desc: "nombre del slot aislado a cerrar (default: demo). Solo se matan procesos DesktopEditors cuyo HOME sea /tmp/oo_<instance>"
- name: --purge
desc: "flag opcional: si se pasa, borra los directorios del slot (/tmp/oo_<instance>*) tras matar los procesos. Sin el flag, solo termina procesos y deja el estado del slot en disco"
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"killed_pids\":[<pids>],\"purged\":true|false,\"status\":\"closed\"|\"not_running\"}. Exit 0 siempre que opere bien (closed si mato procesos, not_running si no habia ninguno del slot), exit 1 si falta dependencia, exit 2 si flag desconocido"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/close_onlyoffice_instance.sh"
---
## Ejemplo
```bash
# Cerrar el slot demo (deja /tmp/oo_demo* en disco para reusar la config)
bash bash/functions/shell/close_onlyoffice_instance.sh demo
# Cerrar y limpiar todo el estado del slot
bash bash/functions/shell/close_onlyoffice_instance.sh demo --purge
# Slot por defecto (demo) sin argumentos
bash bash/functions/shell/close_onlyoffice_instance.sh
# Via fn run
./fn run close_onlyoffice_instance_bash_shell reporte --purge
# Sourceado
source bash/functions/shell/close_onlyoffice_instance.sh
out=$(close_onlyoffice_instance demo --purge)
echo "$out"
# {"instance":"demo","killed_pids":[12345,12350],"purged":true,"status":"closed"}
```
## Cuando usarla
- Cuando terminas un flujo automatizado con ONLYOFFICE Desktop y quieres **cerrar la instancia aislada por completo** (cerrar la ventana con `wmctrl` deja el proceso vivo; esta funcion mata el proceso real).
- Para **liberar recursos** de un slot que ya no usas, opcionalmente borrando su estado en /tmp con `--purge`.
- Como ultimo paso del ciclo open -> reload -> close, garantizando que no quedan procesos huerfanos de la instancia aislada.
## Gotchas
- **Solo mata la instancia aislada**: identifica procesos por `HOME=/tmp/oo_<instance>` en `/proc/<pid>/environ`. La instancia personal del usuario (HOME real) NUNCA se toca. Esto es por diseño y por seguridad.
- **Cerrar la ventana NO mata el proceso**: por eso esta funcion existe. Tras `reload`/`wmctrl -ic` el proceso de la instancia aislada sigue vivo (deseable para reusar). Usa esta funcion para terminarlo de verdad.
- **`--purge` borra /tmp/oo_<instance>***: pierdes la config del slot (perfil, recientes). El slot se recreara limpio en el siguiente `open`. Sin `--purge`, el estado persiste y el siguiente arranque reusa esa config.
- **El slot vive en /tmp**: aunque no purgues, `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
- **Requiere X11 + wmctrl + xdotool** instalados (coherencia con el grupo, aunque esta funcion solo usa /proc para matar). Comprueba `command -v` y falla claro si falta alguna; no funciona en Wayland puro sin XWayland para el resto del grupo.
- **Carrera de /proc**: si un pid muere entre listarlo y leer su environ, se ignora silenciosamente (guardas `2>/dev/null || true`); no rompe la funcion (`set -uo pipefail` sin `-e`).
- **SIGKILL como ultimo recurso**: tras ~3s de SIGTERM, los procesos vivos reciben SIGKILL. Cambios sin guardar en la app (si los hubiera) se pierden — pero el flujo previsto edita en disco, no en la app, asi que no deberia haber estado sin guardar.
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# close_onlyoffice_instance — termina el/los proceso(s) DesktopEditors de una
# INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su
# HOME=/tmp/oo_<instance> en /proc/<pid>/environ. Opcionalmente limpia los
# directorios del slot con --purge.
#
# Funcion impura: lee /proc, envia señales a procesos y (con --purge) borra
# directorios bajo /tmp. NO toca la instancia personal del usuario: solo mata
# procesos cuyo HOME apunta al slot aislado.
#
# Slot aislado: cada instance usa HOME=/tmp/oo_<instance>,
# XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config.
# Sin -e: lecturas de /proc/<pid>/environ pueden fallar por carrera (el pid
# muere entre listar y leer); no deben abortar la funcion.
set -uo pipefail
close_onlyoffice_instance() {
local instance="demo"
local purge=false
# Parseo de args: [instance] y/o --purge en cualquier orden.
local a
for a in "$@"; do
case "$a" in
--purge) purge=true ;;
-*) echo "close_onlyoffice_instance: flag desconocido '$a'" >&2; return 2 ;;
*) instance="$a" ;;
esac
done
# 1. Dependencias del sistema (consistencia con el grupo, aunque aqui solo
# se usa /proc; onlyoffice/wmctrl/xdotool deben existir para operar el slot).
local dep
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "close_onlyoffice_instance: falta dependencia '$dep'" >&2
return 1
fi
done
local oo_home="/tmp/oo_${instance}"
# 2. Encontrar pids de DesktopEditors con HOME=/tmp/oo_<instance>.
local pids=() pid environ
for pid in $(pgrep -f '/opt/onlyoffice/desktopeditors/DesktopEditors' 2>/dev/null || true); do
# Leer el entorno del proceso; saltar si no se puede (carrera/permisos).
environ=$(tr '\0' '\n' <"/proc/${pid}/environ" 2>/dev/null || true)
[[ -z "$environ" ]] && continue
if grep -qx "HOME=${oo_home}" <<<"$environ" 2>/dev/null; then
pids+=("$pid")
fi
done
# 3. Si no hay procesos del slot: not_running (purge opcional igualmente).
if [[ ${#pids[@]} -eq 0 ]]; then
local purged=false
if [[ "$purge" == true ]]; then
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
purged=true
fi
printf '{"instance":"%s","killed_pids":[],"purged":%s,"status":"not_running"}\n' \
"$instance" "$purged"
return 0
fi
# 4. SIGTERM a todos los pids del slot.
kill -TERM "${pids[@]}" 2>/dev/null || true
# 5. Esperar ~3s a que mueran (NUNCA sleep foreground): read -t 0.3 x10.
local w=0 wmax=10
while [[ $w -lt $wmax ]]; do
local alive=false p
for p in "${pids[@]}"; do
if kill -0 "$p" 2>/dev/null; then alive=true; break; fi
done
[[ "$alive" == false ]] && break
read -t 0.3 _ </dev/null 2>/dev/null || true
w=$((w + 1))
done
# 6. SIGKILL a los que sigan vivos.
local p
for p in "${pids[@]}"; do
if kill -0 "$p" 2>/dev/null; then
kill -KILL "$p" 2>/dev/null || true
fi
done
# 7. Purge opcional de los dirs del slot.
local purged=false
if [[ "$purge" == true ]]; then
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
purged=true
fi
# 8. JSON con el array de pids terminados.
local pids_json
pids_json=$(printf '%s,' "${pids[@]}")
pids_json="[${pids_json%,}]"
printf '{"instance":"%s","killed_pids":%s,"purged":%s,"status":"closed"}\n' \
"$instance" "$pids_json" "$purged"
return 0
}
# Ejecutable directo o sourceado.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
close_onlyoffice_instance "$@"
fi
@@ -0,0 +1,78 @@
---
name: monitor_listening_ports
kind: function
lang: bash
domain: shell
version: "0.3.0"
purity: impure
signature: "monitor_listening_ports([--interval N], [--once]) -> void"
description: "TUI ligera de terminal que refresca cada N segundos una tabla de los sockets TCP en escucha (LISTEN) del equipo local: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO | CMD (cmdline real, util para distinguir python3/node genericos), ordenada por tiempo de vida del proceso dueño (descendente). Una fila por pid. Lanzada como root rellena tambien los sockets de otros usuarios. Modo --once imprime un solo frame y sale."
tags: [recon, ports, monitor, tui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: --interval N
desc: "segundos entre refrescos en modo bucle (default: 1, acepta decimales)"
- name: --once
desc: "imprime un único frame de la tabla y termina con exit 0 (no interactivo; úsalo en tests y en `fn run` para no colgar)"
output: "tabla a stdout con columnas IP, PUERTO, PROCESO, PID, TIEMPO ACTIVO ordenada por uptime del proceso descendente; sin --once refresca en bucle infinito hasta Ctrl-C"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/monitor_listening_ports.sh"
---
## Ejemplo
```bash
# Un solo frame (no cuelga) — ideal para fn run o un pipe
./fn run monitor_listening_ports_bash_shell --once
# Como script directo
bash bash/functions/shell/monitor_listening_ports.sh --once
# Sourceada, en bucle interactivo refrescando cada segundo (Ctrl-C para salir)
source bash/functions/shell/monitor_listening_ports.sh
monitor_listening_ports --interval 1
# Refresco mas lento
monitor_listening_ports --interval 5
```
Salida (frame `--once`, recortado):
```
IP PUERTO PROCESO PID TIEMPO ACTIVO
* 8420 registry_api 1885 4d 23:40:46
:: 8889 mitmweb 1892 4d 23:40:46
127.0.0.1 8484 sqlite_api 1889 4d 23:40:42
127.0.0.1 8899 jupyter-lab 155100 4d 19:33:55
::1 631 - - ?
```
## Cuando usarla
- Cuando quieras vigilar **qué puertos abren tus dev-servers / procesos web locales y desde cuándo** llevan vivos, en una sola pantalla que se actualiza sola.
- Para detectar de un vistazo un proceso recién levantado (aparece al fondo, con poco TIEMPO ACTIVO) o uno que lleva días escuchando (arriba del todo).
- Como paso de reconocimiento local del grupo `recon`: inventario rápido de superficie de escucha TCP del propio equipo, con el dueño de cada socket.
- En tests o automatizaciones que solo necesitan un snapshot: añade `--once` para obtener un frame y salir.
## Gotchas
- **Impura**: depende de `ss` (paquete iproute2) y `ps` (procps). Si falta cualquiera, sale con exit 1 y un mensaje a stderr.
- **Sin sudo no ves PROCESO/PID/CMD de sockets de otros usuarios** (típicamente procesos de root, ej. systemd-resolved en `127.0.0.54:53`, kernels Jupyter de otra sesión, o servidores en contenedores). Esas filas muestran `-`/`?`. La función **no usa sudo** a propósito; para **rellenarlos, lánzala como root**: `pass show claude/sudo | sudo -S bash bash/functions/shell/monitor_listening_ports.sh --interval 1` (el password se pipea, no queda en la cmdline). Como root, `ss` resuelve el dueño de todos los sockets.
- **Columna CMD = cmdline real** (`ps -o args=`, recortada a 90 chars). Es lo que distingue un `python3`/`node` genérico (PROCESO) de lo que realmente ejecuta: `python3 -m ipykernel_launcher ...`, `registry_api -port 8420`, etc. Procesos en distinto namespace (docker) pueden seguir sin CMD aunque corras como root.
- **Una fila por pid**: un mismo puerto con varios workers (ej. nginx, gunicorn) genera varias filas, una por cada pid dueño del socket.
- **`--once` evita colgar**: sin `--once` corre en bucle infinito. No lo lances así en tests ni en `fn run` desatendido — usa `--once`.
- **El orden es por uptime del PROCESO, no por el tiempo de la conexión**. `ps -o etimes=` mide cuánto lleva vivo el proceso completo, no cuándo abrió ese socket concreto.
- **Carrera ps**: si un pid muere entre `ss` y `ps`, su TIEMPO ACTIVO sale como `?` y la fila se ordena al final (no rompe el bucle; el script usa `set -uo pipefail` sin `-e`).
- En modo bucle oculta el cursor (`tput civis`) y lo restaura + limpia en un `trap` EXIT/INT/TERM, de modo que Ctrl-C deja la terminal limpia.
## Capability growth log
- v0.3.0 (14/06/2026) — añade columna **CMD** con la cmdline real del proceso (mapa pid→args construido en la misma llamada `ps -eo pid=,etimes=,args=`), para distinguir un `python3`/`node` genérico de lo que realmente ejecuta. Documenta cómo rellenar los sockets de otros usuarios (`-`) lanzando la TUI como root. Anchos de columna reajustados para dar sitio a CMD.
- v0.2.0 (14/06/2026) — corrige parpadeo y cuelgue del modo bucle. (1) Doble-buffer ANSI: cada frame se computa completo en una variable y se pinta con cursor-home `\033[H` + clear-to-end `\033[J` en vez de `tput clear` antes de recolectar, eliminando el instante en blanco. (2) Rendimiento: una sola llamada a `ps -eo pid=,etimes=` (mapa pid→uptime en memoria, antes era un fork de `ps` por pid) y construcción de filas con `printf -v` (builtin, antes un `$( )` por fila); frame de ~130 ms con cientos de sockets. (3) Bugfix de cuelgue: el avance del parser multi-pid usaba `BASH_REMATCH[0]`, que queda sobrescrito por el `[[ =~ ]]` interno de `_mlp_fmt_etime` → no recortaba el string y entraba en bucle infinito. Ahora el needle se captura justo tras el match, con guard anti-cuelgue si el recorte no progresa.
@@ -0,0 +1,271 @@
#!/usr/bin/env bash
# monitor_listening_ports — TUI ligera que refresca una tabla de sockets TCP en
# escucha (LISTEN) del equipo local, ordenada por tiempo de vida del proceso
# dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
#
# Funcion impura: lee estado del sistema (sockets via `ss`, uptime de procesos
# via `ps`). Sin --once corre en bucle infinito refrescando cada N segundos.
#
# Rendimiento: cada frame hace UNA sola llamada a `ss` y UNA sola a `ps`
# (mapa pid->etimes en memoria). El parseo de cada socket es bash puro y SIN
# command substitution por fila: las cadenas se construyen con `printf -v`
# (builtin, cero forks) y el formato de tiempo se devuelve en una variable
# global. El modo bucle usa doble-buffer ANSI (cursor home + clear-to-end) en
# lugar de limpiar la pantalla antes de computar, para que nunca se vea vacia
# entre refrescos.
# No usamos -e a proposito: una carrera donde un pid muere entre `ss` y `ps`
# no debe matar el bucle entero. -u y pipefail se mantienen para robustez.
set -uo pipefail
# Formatea segundos a texto humano legible y lo deja en la global _mlp_human.
# Se evita `$( )` (un fork por fila) usando una variable de retorno.
# <1h -> MM:SS ej. 12:45
# <1d -> HH:MM:SS ej. 03:12:45
# >=1d -> Nd HH:MM:SS ej. 1d 03:12:45
_mlp_human=""
_mlp_fmt_etime() {
local secs="$1"
# Si no es un numero entero valido, devolver tal cual (ej. "?").
if ! [[ "$secs" =~ ^[0-9]+$ ]]; then
_mlp_human="$secs"
return 0
fi
local days=$(( secs / 86400 ))
local rem=$(( secs % 86400 ))
local hours=$(( rem / 3600 ))
local mins=$(( (rem % 3600) / 60 ))
local s=$(( rem % 60 ))
if (( days > 0 )); then
printf -v _mlp_human '%dd %02d:%02d:%02d' "$days" "$hours" "$mins" "$s"
elif (( hours > 0 )); then
printf -v _mlp_human '%02d:%02d:%02d' "$hours" "$mins" "$s"
else
printf -v _mlp_human '%02d:%02d' "$mins" "$s"
fi
}
# Imprime un unico frame de la tabla a stdout.
# Estrategia de rendimiento (cero forks por fila):
# 1. Un solo `ps -eo pid=,etimes=` construye un mapa pid -> segundos vivo.
# 2. Un solo `ss -H -tlnp` lista los sockets en escucha.
# 3. Cada linea se parsea con bash puro: IP/puerto por parameter expansion,
# (nombre,pid) del campo users:(...) iterando con BASH_REMATCH, y cada
# fila se arma con `printf -v` (builtin). El uptime se resuelve por lookup
# O(1) en el mapa.
# 4. Se ordena por segundos vivo descendente con un unico `sort`.
_mlp_render_frame() {
# Mapas pid -> etimes (segundos vivo) y pid -> cmdline completa. Una sola
# invocacion de ps por frame. `args=` va al ultimo porque lleva espacios,
# asi `read` lo captura entero en la tercera variable.
local -A etmap=() argmap=()
local _pid _et _args
while read -r _pid _et _args; do
[[ -z "$_pid" ]] && continue
etmap["$_pid"]="$_et"
argmap["$_pid"]="$_args"
done < <(ps -eo pid=,etimes=,args= 2>/dev/null)
# Cada fila intermedia: "<etimes>\t<ip>\t<puerto>\t<proceso>\t<pid>\t<humano>"
local -a rows=()
local line row
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# Campos de `ss -H -tlnp`: State Recv-Q Send-Q Local:Port Peer:Port users:(...)
# Local:Port es el 4o token. Lo extraemos sin fork con read en array.
local -a F=()
read -ra F <<<"$line"
local local_addr="${F[3]:-}"
[[ -z "$local_addr" ]] && continue
# Separar IP y PUERTO partiendo por el ULTIMO ':'.
local ip port
port="${local_addr##*:}"
ip="${local_addr%:*}"
# Quitar corchetes de IPv6: [::] -> :: , [::1] -> ::1
ip="${ip#[}"
ip="${ip%]}"
# Caso de bind sin direccion explicita (raro): dejar marcador.
[[ -z "$ip" ]] && ip="*"
# Extraer el bloque users:(...) del final de la linea (si existe).
local users=""
[[ "$line" == *"users:("* ]] && users="${line#*users:(}"
if [[ -z "$users" ]]; then
# Socket sin info de proceso (pertenece a otro usuario y no corremos
# como root). Para verlo, lanzar la TUI como root (ver Gotchas).
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
rows+=("$row")
continue
fi
# Dentro de users puede haber varios ("nombre",pid=N,fd=M). Una fila por
# pid. Iteramos con BASH_REMATCH avanzando sobre el string (cero forks).
local s="$users" pname pid etimes needle prev_s cmd found_any=0
while [[ "$s" =~ \"([^\"]*)\",pid=([0-9]+) ]]; do
# IMPORTANTE: capturar nombre/pid/needle ANTES de cualquier otra
# comparacion `[[ =~ ]]` (p.ej. dentro de _mlp_fmt_etime), porque
# cada `=~` SOBREESCRIBE BASH_REMATCH. Si se usara BASH_REMATCH[0]
# despues, contendria el match del ultimo `=~` y el recorte de `s`
# no avanzaria -> bucle infinito.
pname="${BASH_REMATCH[1]}"
pid="${BASH_REMATCH[2]}"
needle="${BASH_REMATCH[0]}"
found_any=1
# Lookup O(1) en el mapa. Si el pid ya no esta (carrera), marcar "?".
etimes="${etmap[$pid]:-}"
if [[ -z "$etimes" || ! "$etimes" =~ ^[0-9]+$ ]]; then
etimes="-1"
_mlp_human="?"
else
_mlp_fmt_etime "$etimes"
fi
# Comando real (cmdline completa) del pid; dice QUE es realmente un
# "python3"/"node" generico. Se recorta para no romper la tabla.
cmd="${argmap[$pid]:-}"
[[ -z "$cmd" ]] && cmd="-"
cmd="${cmd:0:90}"
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "$etimes" "$ip" "$port" "$pname" "$pid" "$_mlp_human" "$cmd"
rows+=("$row")
# Avanzar mas alla del match actual para no repetir el primer pid.
# Guard: si el recorte no cambia `s`, cortar para no colgar nunca.
prev_s="$s"
s="${s#*"$needle"}"
[[ "$s" == "$prev_s" ]] && break
done
# Si el formato fue inesperado y no se parseo ningun par, fila placeholder.
if (( found_any == 0 )); then
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
rows+=("$row")
fi
done < <(ss -H -tlnp 2>/dev/null)
# Estilo de cabecera (negrita) si la terminal lo soporta.
local bold="" reset=""
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
bold=$(tput bold 2>/dev/null || true)
reset=$(tput sgr0 2>/dev/null || true)
fi
# Anchos fijos para alineacion estable (no usamos column -t). La ultima
# columna (CMD) es libre: muestra la cmdline real del proceso.
local fmt='%-26s %-7s %-16s %-8s %-13s %s\n'
# shellcheck disable=SC2059
printf "${bold}${fmt}${reset}" "IP" "PUERTO" "PROCESO" "PID" "TIEMPO ACTIVO" "CMD"
if (( ${#rows[@]} == 0 )); then
printf '(sin sockets TCP en escucha)\n'
return 0
fi
# Ordenar por la primera columna (etimes) numerica descendente y emitir las
# 5 columnas visibles (descartando la columna de orden).
printf '%s\n' "${rows[@]}" \
| sort -t$'\t' -k1,1nr \
| while IFS=$'\t' read -r _etimes ip port pname pid human cmd; do
# shellcheck disable=SC2059
printf "$fmt" "$ip" "$port" "$pname" "$pid" "$human" "$cmd"
done
}
monitor_listening_ports() {
local interval=1
local once=0
# Parseo de flags.
while (( $# > 0 )); do
case "$1" in
--interval)
interval="${2:-1}"
shift 2
;;
--interval=*)
interval="${1#*=}"
shift
;;
--once)
once=1
shift
;;
-h|--help)
cat <<'USAGE'
monitor_listening_ports [--interval N] [--once]
--interval N Segundos entre refrescos (default: 1, acepta decimales).
--once Imprime un solo frame de la tabla y termina (exit 0).
Tabla de sockets TCP en escucha (LISTEN) ordenada por tiempo de vida del
proceso dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
USAGE
return 0
;;
*)
printf 'monitor_listening_ports: argumento desconocido: %s\n' "$1" >&2
return 1
;;
esac
done
# Dependencias minimas.
if ! command -v ss >/dev/null 2>&1; then
printf 'monitor_listening_ports: requiere `ss` (paquete iproute2)\n' >&2
return 1
fi
if ! command -v ps >/dev/null 2>&1; then
printf 'monitor_listening_ports: requiere `ps` (paquete procps)\n' >&2
return 1
fi
# Modo single-frame: util para tests y para `fn run` sin colgar.
if (( once == 1 )); then
_mlp_render_frame
return 0
fi
# Modo bucle interactivo: oculta cursor y lo restaura + limpia al salir.
local have_tput=0
command -v tput >/dev/null 2>&1 && have_tput=1
_mlp_cleanup() {
if (( have_tput == 1 )); then
tput cnorm 2>/dev/null || true # restaurar cursor
tput sgr0 2>/dev/null || true # resetear atributos
fi
printf '\n'
}
trap '_mlp_cleanup; trap - INT TERM EXIT; return 0 2>/dev/null || exit 0' INT TERM EXIT
(( have_tput == 1 )) && tput civis 2>/dev/null || true # ocultar cursor
# Limpiamos la pantalla UNA sola vez al entrar. A partir de aqui cada frame
# se computa COMPLETO en una variable y luego se pinta con doble-buffer:
# cursor a home (\033[H), volcado del frame, y clear-to-end (\033[J) para
# borrar restos de un frame anterior mas largo. Asi nunca hay un instante
# con la pantalla vacia mientras se recolectan los datos.
printf '\033[2J'
local frame
while true; do
frame=$(
printf 'monitor_listening_ports — %s — intervalo %ss — orden: TIEMPO ACTIVO desc (Ctrl-C para salir)\n\n' \
"$(date '+%d/%m/%Y %H:%M:%S')" "$interval"
_mlp_render_frame
)
printf '\033[H' # cursor al inicio (sin borrar todavia)
printf '%s\n' "$frame" # volcar el frame ya calculado de golpe
printf '\033[J' # borrar de aqui al final (restos del frame previo)
sleep "$interval" || break
done
}
# Auto-invocacion cuando se ejecuta como script (no al hacer source).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
monitor_listening_ports "$@"
fi
@@ -0,0 +1,62 @@
---
name: open_onlyoffice_file
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "open_onlyoffice_file(file_path: string, instance: string = demo) -> json"
description: "Abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE Desktop Editors (Linux/X11) sin perturbar la instancia personal del usuario. Cada 'instance' (slot, default demo) usa su propio HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR y XDG_CONFIG_HOME bajo /tmp, lo que rompe el single-instance lock de ONLYOFFICE y permite una ventana propia en vez de una pestaña en la instancia del usuario. Espera la ventana por evento (xdotool, basename del archivo, timeout ~25s) sin sleep en foreground. Idempotente: si ya hay ventana para ese basename, no relanza y devuelve el wid existente. NO crea archivos: si file_path no existe, falla. Imprime una linea JSON con instance, file (ruta absoluta), wid (hex), pid y status (open|timeout)."
tags: [onlyoffice, desktop, x11, shell]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: file_path
desc: "ruta (relativa o absoluta) al archivo a abrir; DEBE existir, esta funcion no crea archivos. Se normaliza con readlink -f y se busca la ventana por su basename"
- name: instance
desc: "nombre del slot aislado (default: demo). Determina el env: HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config. Usa el MISMO instance en reload/close para operar la misma instancia"
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"pid\":<n>|null,\"status\":\"open\"|\"timeout\"}. Exit 0 si abrio (status open), exit 1 si la ventana no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta el argumento file_path"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/open_onlyoffice_file.sh"
---
## Ejemplo
```bash
# Como script directo (slot 'demo' por defecto)
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/demo_reload.xlsx
# Slot nombrado distinto (ventana propia, no perturba la instancia personal)
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/informe.docx reporte
# Via fn run
./fn run open_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
# Sourceado, capturando el wid del JSON
source bash/functions/shell/open_onlyoffice_file.sh
out=$(open_onlyoffice_file /tmp/demo_reload.xlsx demo)
echo "$out"
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid":"0x3c00007","pid":12345,"status":"open"}
```
## Cuando usarla
- Cuando necesites **abrir un archivo en ONLYOFFICE Desktop desde terminal en su propia ventana aislada**, sin que se agregue como pestaña a la instancia personal del usuario.
- Como primer paso de un flujo automatizado open -> (editas el archivo en disco) -> `reload_onlyoffice_file` -> `close_onlyoffice_instance`.
- Cuando quieras un slot reproducible por nombre (`instance`) que reuse la misma instancia aislada entre llamadas (reabrir rapido en vez de arrancar el motor de cero).
## Gotchas
- **ONLYOFFICE Desktop es single-instance por usuario**: sin el slot aislado (HOME/XDG_RUNTIME_DIR propios), un segundo lanzamiento se reenvia a la instancia viva y abre el archivo como PESTAÑA, no ventana nueva. El lock NO se rompe con XDG_CONFIG_HOME solo; SI con HOME + XDG_RUNTIME_DIR propios. Esta funcion ya aplica esa convencion.
- **NO hay reload nativo de cambios externos** (GitHub Issue #2313 abierto, no implementado). Esta funcion solo abre; para reflejar ediciones hechas en disco hay que cerrar+reabrir con `reload_onlyoffice_file`.
- **NO crea archivos**: si `file_path` no existe, falla con exit 1. Crea el archivo por tu cuenta antes de llamar.
- **El slot vive en /tmp**: los dirs `/tmp/oo_<instance>*` se pierden al reiniciar el PC (tmpfs en muchos sistemas). No guardes nada importante ahi; es estado desechable de la instancia aislada.
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland (xdotool no encontrara la ventana). La funcion comprueba `command -v` de las 3 deps y falla claro si falta alguna.
- **El pid reportado es el del launcher** (`onlyoffice-desktopeditors`), que puede reexec/fork al proceso real `DesktopEditors`; sirve como referencia best-effort, no para `kill` fiable (usa `close_onlyoffice_instance`, que localiza el proceso real por su HOME).
- **Idempotencia por basename**: si ya existe una ventana cuyo titulo contiene el basename del archivo (lo abrio el usuario en su instancia personal, por ejemplo), la funcion la considera "ya abierta" y devuelve ese wid sin relanzar. Usa un basename unico para el slot de pruebas si quieres evitar colisiones.
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# open_onlyoffice_file — abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE
# Desktop Editors (Linux/X11), sin perturbar la instancia personal del usuario.
#
# Funcion impura: lanza un proceso GUI, lee estado de ventanas (xdotool) y
# escribe directorios en /tmp. Imprime una linea JSON con el resultado.
#
# Por que "instancia aislada": ONLYOFFICE Desktop es single-instance por
# usuario — un segundo `onlyoffice-desktopeditors <file>` se reenvia a la
# instancia viva y abre el archivo como PESTAÑA en su ventana. El lock
# single-instance NO se rompe con XDG_CONFIG_HOME, pero SI se rompe lanzando
# con HOME y XDG_RUNTIME_DIR propios. Por eso cada "slot" nombrado (instance)
# usa su propio HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp.
# Sin -e: las busquedas de ventana (xdotool search) pueden no matchear y
# devolver exit !=0; no deben abortar la funcion. -u y pipefail se mantienen.
set -uo pipefail
open_onlyoffice_file() {
local file_path="${1:-}"
local instance="${2:-demo}"
if [[ -z "$file_path" ]]; then
echo "open_onlyoffice_file: falta <file_path>" >&2
echo "uso: open_onlyoffice_file <file_path> [instance]" >&2
return 2
fi
# 1. Dependencias del sistema.
local dep
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "open_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
return 1
fi
done
# 2. El archivo DEBE existir — esta funcion no crea archivos.
if [[ ! -f "$file_path" ]]; then
echo "open_onlyoffice_file: el archivo no existe: $file_path (esta funcion no crea archivos)" >&2
return 1
fi
# Ruta absoluta y basename para titular/buscar la ventana.
local abs_path base
abs_path=$(readlink -f -- "$file_path")
base=$(basename -- "$abs_path")
# 3. Slot aislado: HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME propios bajo /tmp.
local oo_home="/tmp/oo_${instance}"
local oo_run="/tmp/oo_${instance}_run"
local oo_cfg="${oo_home}/.config"
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
chmod 700 "$oo_run" 2>/dev/null || true
# 4. Idempotencia: si ya hay ventana para ese basename, no relanzar.
local existing_wid
existing_wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
if [[ -n "$existing_wid" ]]; then
local wid_hex
wid_hex=$(printf '0x%x' "$existing_wid" 2>/dev/null || echo "$existing_wid")
printf '{"instance":"%s","file":"%s","wid":"%s","pid":null,"status":"open"}\n' \
"$instance" "$abs_path" "$wid_hex"
return 0
fi
# 5. Lanzar la instancia aislada con su env propio. setsid lo desacopla de
# la terminal; redirige todo a un log del slot.
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
setsid onlyoffice-desktopeditors "$abs_path" \
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
local launch_pid=$!
# 6. Esperar la ventana por evento (NUNCA sleep en foreground).
# ~25s con read -t 0.3 => ~83 iteraciones.
local wid="" i=0 max=83
while [[ $i -lt $max ]]; do
wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
[[ -n "$wid" ]] && break
read -t 0.3 _ </dev/null 2>/dev/null || true
i=$((i + 1))
done
if [[ -z "$wid" ]]; then
printf '{"instance":"%s","file":"%s","wid":null,"pid":%s,"status":"timeout"}\n' \
"$instance" "$abs_path" "$launch_pid"
return 1
fi
local wid_hex
wid_hex=$(printf '0x%x' "$wid" 2>/dev/null || echo "$wid")
# El pid del proceso real (DesktopEditors) puede diferir del launcher; el
# launcher reexec/fork. Reportamos el pid del launcher (best-effort).
printf '{"instance":"%s","file":"%s","wid":"%s","pid":%s,"status":"open"}\n' \
"$instance" "$abs_path" "$wid_hex" "$launch_pid"
return 0
}
# Ejecutable directo: `bash open_onlyoffice_file.sh <file> [instance]`.
# Sourceado: define la funcion sin ejecutarla.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
open_onlyoffice_file "$@"
fi
@@ -0,0 +1,61 @@
---
name: reload_onlyoffice_file
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "reload_onlyoffice_file(file_path: string, instance: string = demo) -> json"
description: "Recarga en la ventana de ONLYOFFICE Desktop Editors los datos que el caller edito EN DISCO, cerrando y reabriendo el archivo en la INSTANCIA AISLADA (slot). Es la funcion estrella del grupo: ONLYOFFICE no recarga cambios externos del archivo (GitHub Issue #2313 abierto, no implementado), asi que la unica forma de mostrar datos editados fuera de la app es cerrar la ventana (wmctrl -ic) y reabrir (ONLYOFFICE lee fresco del disco al abrir). Localiza la ventana por basename, la cierra y espera a que desaparezca (timeout ~10s), relanza con el env del slot aislado y espera la ventana nueva (timeout ~25s), todo por evento sin sleep en foreground. Si no habia ventana previa, actua como open. NO edita el archivo: el caller lo edita antes de llamar. Imprime JSON con wid_old, wid_new, reopened, elapsed_s y status (reloaded|timeout)."
tags: [onlyoffice, desktop, x11, shell]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: file_path
desc: "ruta (relativa o absoluta) al archivo cuya ventana se recarga; DEBE existir. El caller ya lo edito en disco antes de llamar. Se busca la ventana por su basename"
- name: instance
desc: "nombre del slot aislado (default: demo); debe coincidir con el usado en open_onlyoffice_file para reusar la misma instancia. Determina HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp"
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid_old\":\"<hex>|null\",\"wid_new\":\"<hex>|null\",\"reopened\":true|false,\"elapsed_s\":<n>,\"status\":\"reloaded\"|\"timeout\"}. Exit 0 si reabrio (status reloaded), exit 1 si la ventana nueva no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta file_path"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/reload_onlyoffice_file.sh"
---
## Ejemplo
```bash
# Flujo tipico: editas el .xlsx en disco con tu herramienta y refrescas la vista
# (este ejemplo asume que /tmp/demo_reload.xlsx ya esta abierto en el slot demo)
bash bash/functions/shell/reload_onlyoffice_file.sh /tmp/demo_reload.xlsx demo
# Via fn run
./fn run reload_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
# Sourceado, dentro de un bucle de "editar en disco -> ver en ONLYOFFICE"
source bash/functions/shell/reload_onlyoffice_file.sh
# ... el caller modifica /tmp/demo_reload.xlsx por su cuenta ...
out=$(reload_onlyoffice_file /tmp/demo_reload.xlsx demo)
echo "$out"
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid_old":"0x3c00007","wid_new":"0x3c0000b","reopened":true,"elapsed_s":4,"status":"reloaded"}
```
## Cuando usarla
- Cuando **editaste un archivo en disco fuera de ONLYOFFICE** (script, otra herramienta, generador) y necesitas que la ventana de ONLYOFFICE muestre los datos nuevos: esta funcion cierra y reabre para forzar la lectura fresca del disco.
- En bucles de iteracion rapida "modificar el archivo -> ver el resultado en ONLYOFFICE" sin tocar la instancia personal del usuario.
- Como reemplazo del reload nativo inexistente (Issue #2313): es la unica via fiable de refrescar la vista desde disco.
## Gotchas
- **No edita el archivo**: solo recarga la ventana desde disco. El caller es responsable de modificar el archivo ANTES de llamar; si no lo modifico, reabrira los mismos datos.
- **ONLYOFFICE no tiene reload de cambios externos** (GitHub Issue #2313 abierto, no implementado): por eso esta funcion existe y hace cerrar+reabrir. No hay forma "in-place" de refrescar.
- **`wmctrl -ic` puede disparar el dialogo "Guardar cambios"** si el usuario edito EN la app (no en disco) y hay cambios sin guardar en esa ventana. El flujo previsto es editar SOLO en disco con la ventana sin tocar; si editaste en la app, guarda o descarta antes, o el cierre se quedara esperando interaccion (la funcion saldra por timeout).
- **Single-instance + slot aislado**: usa el mismo `instance` que en `open_onlyoffice_file`. Con HOME/XDG_RUNTIME_DIR propios el relaunch reenvia a la instancia aislada viva y reabre rapido; con env por defecto se reenviaria a la instancia personal del usuario (no deseado).
- **El slot vive en /tmp**: `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland. Comprueba las 3 deps y falla claro si falta alguna.
- **Carrera de cierre**: si la ventana tarda mas de ~10s en cerrarse (dialogo modal, app ocupada), la funcion continua igualmente al relaunch; el resultado puede acabar en `timeout` si la ventana nueva no aparece a tiempo.
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# reload_onlyoffice_file — cierra y reabre un archivo en la INSTANCIA AISLADA de
# ONLYOFFICE Desktop Editors para que la ventana muestre los datos editados
# EN DISCO por el caller (ONLYOFFICE no recarga cambios externos: GitHub Issue
# #2313 abierto, no implementado — la unica forma es cerrar+reabrir).
#
# Funcion impura: cierra una ventana GUI (wmctrl), relanza un proceso y espera
# la ventana nueva por evento. NO edita el archivo — solo recarga la ventana
# desde el disco. El caller edita el archivo antes de llamar a esta funcion.
#
# Instancia aislada (slot): mismo HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME que usa
# open_onlyoffice_file, para que el relaunch reenvie a la instancia aislada
# viva y reabra rapido en vez de arrancar el motor de cero.
# Sin -e: busquedas de ventana (xdotool/wmctrl) pueden no matchear; no deben
# abortar la funcion. -u y pipefail se mantienen.
set -uo pipefail
reload_onlyoffice_file() {
local file_path="${1:-}"
local instance="${2:-demo}"
if [[ -z "$file_path" ]]; then
echo "reload_onlyoffice_file: falta <file_path>" >&2
echo "uso: reload_onlyoffice_file <file_path> [instance]" >&2
return 2
fi
# 1. Dependencias del sistema.
local dep
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "reload_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
return 1
fi
done
# 2. El archivo DEBE existir — no editamos ni creamos archivos.
if [[ ! -f "$file_path" ]]; then
echo "reload_onlyoffice_file: el archivo no existe: $file_path" >&2
return 1
fi
local abs_path base
abs_path=$(readlink -f -- "$file_path")
base=$(basename -- "$abs_path")
# 3. Slot aislado (identico a open_onlyoffice_file).
local oo_home="/tmp/oo_${instance}"
local oo_run="/tmp/oo_${instance}_run"
local oo_cfg="${oo_home}/.config"
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
chmod 700 "$oo_run" 2>/dev/null || true
local start_ts
start_ts=$(date +%s)
# 4. Localizar la ventana actual del archivo por basename.
local wid_old=""
wid_old=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
local wid_old_hex="null"
if [[ -n "$wid_old" ]]; then
wid_old_hex=$(printf '0x%x' "$wid_old" 2>/dev/null || echo "$wid_old")
# 5. Cerrar la ventana (sin teclear en la app) y esperar a que
# desaparezca (~10s con read -t 0.3 => ~33 iteraciones).
wmctrl -ic "$wid_old" 2>/dev/null || true
local g=0 gmax=33
while [[ $g -lt $gmax ]]; do
if ! xdotool search --name -- "$base" 2>/dev/null | grep -q .; then
break
fi
read -t 0.3 _ </dev/null 2>/dev/null || true
g=$((g + 1))
done
fi
# 6. Relanzar con el env del slot aislado. (Si no habia ventana previa,
# esto actua simplemente como open.)
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
setsid onlyoffice-desktopeditors "$abs_path" \
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
# 7. Esperar la ventana nueva por evento (~25s => ~83 iteraciones).
local wid_new="" i=0 max=83
while [[ $i -lt $max ]]; do
wid_new=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
# Si hubo ventana previa, aceptar cualquier wid que aparezca (el old
# ya se cerro; el nuevo puede reutilizar id o no). Si no la hubo,
# cualquier wid sirve.
[[ -n "$wid_new" ]] && break
read -t 0.3 _ </dev/null 2>/dev/null || true
i=$((i + 1))
done
local now_ts elapsed
now_ts=$(date +%s)
elapsed=$((now_ts - start_ts))
if [[ -z "$wid_new" ]]; then
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":null,"reopened":false,"elapsed_s":%s,"status":"timeout"}\n' \
"$instance" "$abs_path" "$wid_old_hex" "$elapsed"
return 1
fi
local wid_new_hex
wid_new_hex=$(printf '0x%x' "$wid_new" 2>/dev/null || echo "$wid_new")
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":"%s","reopened":true,"elapsed_s":%s,"status":"reloaded"}\n' \
"$instance" "$abs_path" "$wid_old_hex" "$wid_new_hex" "$elapsed"
return 0
}
# Ejecutable directo o sourceado.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
reload_onlyoffice_file "$@"
fi
@@ -0,0 +1,90 @@
---
name: save_onlyoffice_file
kind: function
lang: bash
domain: shell
purity: impure
version: 1.1.0
description: "Fuerza el guardado (Ctrl+S) de un documento abierto en una instancia de OnlyOffice Desktop en Linux/X11 y confirma que llego a disco por cambio de mtime. Primer paso del flujo seguro guardar -> actualizar -> recargar; evita perder cambios no guardados cuando un build regenera el archivo leyendo del disco."
signature: "save_onlyoffice_file(file_path: string, [instance: string]) -> json"
error_type: error_go_core
tags: [onlyoffice, desktop, x11, gui, save, persist]
uses_functions: []
uses_types: []
file_path: bash/functions/shell/save_onlyoffice_file.sh
params:
- name: file_path
desc: "ruta al documento abierto en OnlyOffice cuyo guardado se quiere forzar. Debe existir. Se normaliza a ruta absoluta y se usa su basename para localizar la ventana."
- name: instance
desc: "nombre del slot/instancia para etiquetar la salida JSON (default: 'demo'). Usar el MISMO valor que en open/reload/close del mismo documento por coherencia."
output: "linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"status\":\"saved\"|\"no_change\"|\"no_window\",\"dialog_confirmed\":0|1[,\"mtime_before\":N,\"mtime_after\":N]}. dialog_confirmed=1 si se envio Return para cerrar el dialogo modal de formato. Exit 0 salvo error de dependencia o archivo inexistente (exit 1)."
---
Fuerza el guardado (Ctrl+S) de un documento abierto en una instancia de ONLYOFFICE
Desktop Editors en Linux/X11 y confirma que el guardado llegó a disco observando el
cambio de `mtime` del archivo.
Existe para cerrar una ventana de pérdida de datos: OnlyOffice mantiene los cambios
en memoria hasta que el usuario guarda. Cualquier proceso que regenere el archivo
leyendo del disco (un build que refresca hojas, un script de sincronización)
perdería el trabajo manual no guardado. Esta función vuelca ese trabajo a disco
ANTES de tocar el archivo, de modo que el paso de actualización pueda preservarlo.
Es el primer paso del flujo seguro de refresco:
```
save_onlyoffice_file -> (actualizar el archivo en disco) -> reload_onlyoffice_file
```
## Ejemplo
```bash
# Forzar el guardado de un xlsx abierto en la instancia "afiliados"
bash bash/functions/shell/save_onlyoffice_file.sh \
/home/enmanuel/afiliados/programas_afiliados.xlsx afiliados
# {"instance":"afiliados","file":"/home/enmanuel/afiliados/programas_afiliados.xlsx","wid":"0x0a20002a","status":"saved","mtime_before":1718380000,"mtime_after":1718380042}
# Via fn run (tras fn index)
./fn run save_onlyoffice_file /home/enmanuel/afiliados/programas_afiliados.xlsx afiliados
# Encadenado con la actualización y la recarga (flujo seguro completo)
bash bash/functions/shell/save_onlyoffice_file.sh "$XLSX" afiliados
python build_xlsx.py # regenera solo las hojas gestionadas
./fn run reload_onlyoffice_file "$XLSX" afiliados
```
## Cuando usarla
Llámala SIEMPRE justo antes de regenerar o modificar en disco un archivo que el
usuario pueda tener abierto en OnlyOffice, para no pisar sus cambios sin guardar.
Es el primer eslabón del flujo guardar -> actualizar -> recargar. Si no hay ventana
abierta para ese archivo, es un no-op seguro (status `no_window`).
## Gotchas
- **Orden crítico**: guarda ANTES de actualizar el archivo. Si actualizas primero y
guardas OnlyOffice después, OnlyOffice sobrescribe tu actualización con su copia
en memoria (vieja). El flujo correcto es save -> update -> reload.
- **status `no_change`**: el `mtime` no cambió. Normalmente significa que no había
cambios pendientes (no es un error).
- **Auto-confirmación del diálogo de formato (v1.1.0)**: si tras Ctrl+S el guardado no
se completa en ~1.2s, la función asume que OnlyOffice mostró un diálogo modal
("mantener formato") y le envía Return, que acepta la opción por defecto (mantener el
formato actual). El campo `dialog_confirmed` indica si se envió. Si no había diálogo,
el Return va al editor y solo mueve de celda (no altera datos). Para suprimir el
diálogo de forma permanente, desmárcalo en OnlyOffice: Configuración avanzada →
desactivar el aviso de formato al guardar.
- **status `no_window`**: no hay ninguna ventana cuyo título contenga el basename del
archivo. No hay nada que guardar; el disco ya es la única fuente de verdad.
- **Detección por basename**: dos archivos con el mismo nombre en rutas distintas
colisionan al localizar la ventana (igual que open/reload).
- **X11 obligatorio**: depende de `xdotool` (y `stat` de coreutils). No funciona en
Wayland puro sin XWayland.
- **Foco**: la función activa la ventana (`windowactivate --sync`) para que Ctrl+S
llegue al editor. Roba el foco un instante; es esperable.
## Capability growth log
- v1.1.0 (2026-06-15) — auto-confirma el diálogo modal "mantener formato" enviando
Return a la ventana activa cuando el guardado no se completa en ~1.2s; añade el campo
`dialog_confirmed` a la salida JSON.
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# save_onlyoffice_file — fuerza el guardado (Ctrl+S) de un documento abierto en una
# instancia de ONLYOFFICE Desktop Editors en Linux/X11 y confirma que el archivo se
# escribio a disco observando el cambio de mtime.
#
# Para que existe: OnlyOffice mantiene los cambios en memoria hasta que el usuario
# guarda. Cualquier proceso que regenere el .xlsx leyendo del disco (por ejemplo un
# build que refresca hojas) perderia el trabajo manual no guardado. Esta funcion
# vuelca ese trabajo a disco ANTES de tocar el archivo, de modo que el paso de
# actualizacion pueda preservarlo. Es el primer paso del flujo seguro:
# save_onlyoffice_file -> (actualizar el archivo) -> reload_onlyoffice_file
#
# La ventana se localiza por el basename del archivo (OnlyOffice titula la ventana
# "<basename> — ONLYOFFICE"), igual que open_onlyoffice_file. Si no hay ventana
# abierta para ese basename no hay nada que guardar: se devuelve status "no_window"
# con exit 0 (el disco ya es la unica fuente de verdad).
#
# Funcion impura: envia eventos de teclado a X11 (xdotool) y lee el estado del
# sistema de archivos. Imprime una linea JSON con el resultado a stdout.
#
# No usamos `set -e`: los pipelines de busqueda de ventanas (xdotool|head) pueden no
# matchear y no deben abortar el script. Mantenemos -u y pipefail con guardas.
set -uo pipefail
save_onlyoffice_file() {
local file_path="${1:-}"
local instance="${2:-demo}"
# --- 1. Validacion de dependencias del sistema ---
local dep
for dep in xdotool stat; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "error: dependencia ausente: '$dep' (instala xdotool, coreutils)" >&2
return 1
fi
done
# --- 2. Validacion de argumentos ---
if [ -z "$file_path" ]; then
echo "error: uso: save_onlyoffice_file <file_path> [instance]" >&2
return 1
fi
if [ ! -f "$file_path" ]; then
echo "error: el archivo no existe: '$file_path'" >&2
return 1
fi
local abs_path
abs_path="$(cd "$(dirname "$file_path")" && pwd)/$(basename "$file_path")"
local base
base="$(basename "$abs_path")"
# --- 3. Localizar la ventana de OnlyOffice por basename ---
local wid=""
wid="$(xdotool search --name "$base" 2>/dev/null | head -1 || true)"
if [ -z "$wid" ]; then
printf '{"instance":"%s","file":"%s","wid":null,"status":"no_window"}\n' \
"$instance" "$abs_path"
return 0
fi
local hex
hex="$(printf '0x%08x' "$wid" 2>/dev/null || echo "$wid")"
# --- 4. mtime antes de guardar ---
local mtime_before
mtime_before="$(stat -c %Y "$abs_path" 2>/dev/null || echo 0)"
# --- 5. Enfocar la ventana y enviar Ctrl+S ---
xdotool windowactivate --sync "$wid" >/dev/null 2>&1 || true
xdotool key --clearmodifiers --window "$wid" ctrl+s >/dev/null 2>&1 || true
# --- 6. Esperar el guardado; auto-confirmar el dialogo de formato si aparece ---
# OnlyOffice puede mostrar un dialogo modal ("mantener formato") al guardar. Si el
# mtime no cambia en ~1.2s asumimos que hay un modal esperando y le enviamos Return:
# acepta la opcion por defecto, que es mantener el formato actual del archivo. Si no
# habia dialogo, el Return va al editor y solo mueve de celda (inofensivo: no altera
# datos). El intento se repite mientras el guardado no se confirme.
local mtime_after="$mtime_before" i=0 confirmed=0
local max=27 # ~8s a 0.3s por iteracion
until [ "$mtime_after" -gt "$mtime_before" ] || [ "$i" -ge "$max" ]; do
read -r -t 0.3 _ </dev/null 2>/dev/null || true
mtime_after="$(stat -c %Y "$abs_path" 2>/dev/null || echo "$mtime_before")"
i=$((i + 1))
# A partir de ~1.2s sin guardar, confirmar el dialogo modal con Return.
if [ "$i" -ge 4 ] && [ "$mtime_after" -le "$mtime_before" ]; then
local dlg
dlg="$(xdotool getactivewindow 2>/dev/null || true)"
if [ -n "$dlg" ]; then
xdotool key --clearmodifiers --window "$dlg" Return >/dev/null 2>&1 || true
confirmed=1
fi
fi
done
local status="saved"
if [ "$mtime_after" -le "$mtime_before" ]; then
# Sin cambio de mtime: no habia nada pendiente que guardar.
status="no_change"
fi
printf '{"instance":"%s","file":"%s","wid":"%s","status":"%s","dialog_confirmed":%s,"mtime_before":%s,"mtime_after":%s}\n' \
"$instance" "$abs_path" "$hex" "$status" "$confirmed" "$mtime_before" "$mtime_after"
return 0
}
# Ejecutable directo: `bash save_onlyoffice_file.sh <file> [instance]`.
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
save_onlyoffice_file "$@"
fi
+26
View File
@@ -70,6 +70,8 @@ func cmdDoctor(args []string) {
doctorDod(r, jsonOut)
case "e2e-coverage":
doctorE2ECoverage(r, jsonOut)
case "projects":
doctorProjects(r, jsonOut)
default:
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
doctorUsage()
@@ -100,6 +102,7 @@ Subcommands:
modules Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097
dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114)
e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b)
projects Cobertura de projects vs sub-repos Gitea (repo propio + hijos clonables) (issue 0171)
Flags:
--json Salida JSON (para scripting/agentes)
@@ -505,6 +508,29 @@ func doctorML(root string, jsonOut bool) {
fmt.Printf("\nOverall ML environment: %s\n", overall)
}
func doctorProjects(root string, jsonOut bool) {
rows, err := infra.AuditProjectsCoverage(root)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
orphans, oerr := infra.FindOrphanProjectRefs(root)
if oerr != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", oerr)
os.Exit(1)
}
if jsonOut {
emit(map[string]any{
"coverage": rows,
"orphan_project_ids": orphans,
})
return
}
fmt.Print(infra.FormatProjectsCoverage(rows))
fmt.Println("\n--- Check inverso: project_id huérfanos (apps/analysis sin project declarado) ---")
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
}
func emit(v any) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
+74 -6
View File
@@ -1,8 +1,10 @@
package main
import (
"bytes"
"database/sql"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -56,8 +58,19 @@ func cmdRun(args []string) {
os.Exit(1)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// When fn run executes a scoped `go test -run`, mirror its output into a
// buffer so we can detect a "no tests to run" result — which go test reports
// with exit 0 and would otherwise be a silent false-green (e.g. the extracted
// unit_tests names drifted from the code). See issue 0167.
guardGoTest := fn.Lang == "go" && isGoTestRun(cmd)
var outBuf bytes.Buffer
if guardGoTest {
cmd.Stdout = io.MultiWriter(os.Stdout, &outBuf)
cmd.Stderr = io.MultiWriter(os.Stderr, &outBuf)
} else {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
cmd.Stdin = os.Stdin
fmt.Fprintf(os.Stderr, "[fn run] %s (%s/%s) %s\n", fn.ID, fn.Lang, fn.Kind, strings.Join(passArgs, " "))
@@ -66,6 +79,13 @@ func cmdRun(args []string) {
runErr := cmd.Run()
durationMs := time.Since(t0).Milliseconds()
// A scoped go test that matched zero tests is a false-green: treat as failure.
if guardGoTest && runErr == nil && strings.Contains(outBuf.String(), "no tests to run") {
fmt.Fprintf(os.Stderr, "\n[fn run] error: -run no encontro ningun test para %s — los nombres de test extraidos no existen en el codigo; corre 'fn index'\n", fn.ID)
logFnRunTelemetry(registryRoot, fn.ID, durationMs, false, "no_tests_run")
os.Exit(1)
}
exitCode := 0
errClass := ""
if runErr != nil {
@@ -140,7 +160,7 @@ func resolveFunction(db *registry.DB, idOrName string) (*registry.Function, erro
func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
switch fn.Lang {
case "go":
return buildGoCommand(fn, registryRoot, absPath, args)
return buildGoCommand(fn, db, registryRoot, absPath, args)
case "py":
return buildPyRunnerCommand(fn, db, registryRoot, args)
case "bash":
@@ -154,7 +174,7 @@ func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath
}
}
func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
func buildGoCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
dir := filepath.Dir(absPath)
env := append(os.Environ(), "CGO_ENABLED=1")
@@ -168,13 +188,23 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []
return cmd, nil
}
// Library code: if it has tests → go test
// Library code with tests → go test, but scoped to THIS function's tests via
// -run, so a flaky test of a sibling function in the same package does not
// break `fn run`. Test names come from the indexer-extracted unit_tests table
// (parsed from the real .go, reliable), never the .md frontmatter (can drift).
// The cmdRun guard fails the run if -run matches zero tests, preventing a
// silent "no tests to run" false-green. See issue 0167.
if fn.Tested && fn.TestFilePath != "" {
testAbs := filepath.Join(registryRoot, fn.TestFilePath)
if _, err := os.Stat(testAbs); err == nil {
relPkg, _ := filepath.Rel(registryRoot, dir)
pkgPath := "./" + filepath.ToSlash(relPkg)
cmdArgs := append([]string{"test", "-v", "-count=1", "-tags", "fts5", pkgPath}, args...)
cmdArgs := []string{"test", "-v", "-count=1", "-tags", "fts5"}
if names := goTestNames(db, fn.ID); len(names) > 0 {
cmdArgs = append(cmdArgs, "-run", "^("+strings.Join(names, "|")+")$")
}
cmdArgs = append(cmdArgs, pkgPath)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("go", cmdArgs...)
cmd.Dir = registryRoot
cmd.Env = env
@@ -193,6 +223,44 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []
return cmd, nil
}
// goTestNames returns the top-level Go test function names registered for fn in
// the indexer-extracted unit_tests table. These drive `go test -run` so that
// `fn run` only executes the function's own tests, isolating it from flaky tests
// of sibling functions in the same package. Returns nil if none are known (db is
// nil, lookup fails, or no tests extracted), in which case the caller falls back
// to running the whole package.
func goTestNames(db *registry.DB, functionID string) []string {
if db == nil {
return nil
}
uts, err := db.GetUnitTestsByFunction(functionID)
if err != nil {
return nil
}
var names []string
for _, ut := range uts {
if ut.Name != "" {
names = append(names, ut.Name)
}
}
return names
}
// isGoTestRun reports whether cmd is a `go test ... -run ...` invocation, used to
// enable the zero-tests-matched guard in cmdRun.
func isGoTestRun(cmd *exec.Cmd) bool {
var hasTest, hasRun bool
for _, a := range cmd.Args {
switch a {
case "test":
hasTest = true
case "-run":
hasRun = true
}
}
return hasTest && hasRun
}
func buildBashCommand(absPath string, args []string) (*exec.Cmd, error) {
cmdArgs := append([]string{absPath}, args...)
+33
View File
@@ -169,6 +169,39 @@ Para diagnosticar un diff: revisar el PNG actual en
`cpp/build/tests/visual_actual/<demo>.png` vs el golden en
`cpp/tests/golden/<demo>.png`.
### Tests de UI headless (Dear ImGui Test Engine)
`fn::run_app_test` (el harness del Test Engine usado por `/e2e-cpp`) crea la
ventana GLFW **oculta por defecto** (`GLFW_VISIBLE=FALSE`). El contexto OpenGL
real se crea igual, así que el render que el Test Engine ejercita sigue siendo
fiel, pero la ventana nunca se mapea en pantalla: cero parpadeo y no roba foco
mientras corre la suite. Es el comportamiento preferente para tests de
frontend en C++.
Control del modo (en orden de prioridad):
| Mecanismo | Efecto |
|---|---|
| `FN_HEADLESS=0` (env) | Fuerza ventana **visible** — para depurar un test a ojo. |
| `FN_HEADLESS=1` (env) | Fuerza oculta (es el default del path de test). |
| `cfg.headless = true` | Oculta también `fn::run_app` (apps reales, p.ej. smoke/capture). |
| sin nada | `run_app_test` → oculta; `run_app` → visible. |
Cómo correr la suite sin parpadeo:
```bash
# Host con GL nativo (GPU real): binario directo, ventana oculta, sin parpadeo.
./build/linux_tests/apps/<app>/<app>_tests
# CI / WSL sin display: display virtual en RAM (también headless).
xvfb-run -a -s "-screen 0 1280x800x24" \
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
./build/linux_tests/apps/<app>/<app>_tests
# Ver un test a ojo (desactiva headless):
FN_HEADLESS=0 ./build/linux_tests/apps/<app>/<app>_tests
```
### CI gate `check_tested.sh`
`cpp/scripts/check_tested.sh [days]` (default `30`) consulta `registry.db` y
+32
View File
@@ -23,6 +23,7 @@
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <string>
@@ -647,6 +648,25 @@ static void draw_header_badge_on_floating_panels(const AppConfig& cfg) {
}
}
// Resuelve si la ventana GLFW debe crearse oculta (GLFW_VISIBLE=FALSE).
// default_hidden : politica base del path de entrada (apps reales = false,
// tests de UI = true).
// config_headless: AppConfig.headless explicito de la app.
// El entorno FN_HEADLESS gana sobre ambos: "0"/"false" fuerza visible,
// cualquier otro valor no vacio fuerza oculta. Sin la variable, se respeta
// default_hidden || config_headless.
static bool resolve_headless(bool default_hidden, bool config_headless) {
bool hidden = default_hidden || config_headless;
if (const char* e = std::getenv("FN_HEADLESS")) {
if (std::strcmp(e, "0") == 0 || std::strcmp(e, "false") == 0) {
hidden = false;
} else if (e[0] != '\0') {
hidden = true;
}
}
return hidden;
}
int run_app(AppConfig config, std::function<void()> render_fn) {
// Logger primero para capturar fallos del propio init (GLFW, ventana, GL).
if (config.log.file_path != nullptr) {
@@ -672,6 +692,11 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
// Apps reales: ventana visible por defecto. Solo se oculta si la app pide
// headless o el entorno FN_HEADLESS lo fuerza (smoke/capture sin parpadeo).
const bool hidden = resolve_headless(/*default_hidden=*/false, config.headless);
glfwWindowHint(GLFW_VISIBLE, hidden ? GLFW_FALSE : GLFW_TRUE);
GLFWwindow* window = glfwCreateWindow(config.width, config.height, config.title, nullptr, nullptr);
if (!window) {
fprintf(stderr, "Failed to create GLFW window\n");
@@ -1178,6 +1203,13 @@ int run_app_test(AppConfig config,
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
// Tests de frontend: ventana OCULTA por defecto (headless) para no parpadear
// en la pantalla del desarrollador ni robar foco mientras el Test Engine
// ejercita la UI. El contexto GL real se crea igual, asi que el render sigue
// siendo fiel. Opt-out para depurar visualmente: FN_HEADLESS=0.
const bool hidden = resolve_headless(/*default_hidden=*/true, config.headless);
glfwWindowHint(GLFW_VISIBLE, hidden ? GLFW_FALSE : GLFW_TRUE);
GLFWwindow* window = glfwCreateWindow(
config.width, config.height,
config.title ? config.title : "fn_test", nullptr, nullptr);
+15
View File
@@ -101,6 +101,21 @@ struct AppConfig {
int height = 720;
bool vsync = true;
bool viewports = true; // Multi-viewport ON por defecto: ventanas ImGui arrastrables fuera del main window
// Headless: si true, la ventana GLFW se crea oculta (GLFW_VISIBLE=FALSE).
// El contexto OpenGL real se sigue creando y el render ocurre offscreen,
// por lo que las pruebas visuales y de UI siguen siendo fieles, pero la
// ventana nunca se mapea en pantalla (cero parpadeo, no roba foco).
//
// Politica por path:
// - run_app (apps reales): default visible (headless = false).
// - run_app_test (Dear ImGui Test Engine): default OCULTA. Los tests de
// frontend corren headless salvo opt-out explicito para debug visual.
//
// Override por entorno (gana sobre el default del path y sobre este flag):
// FN_HEADLESS=1 / true -> fuerza ventana oculta.
// FN_HEADLESS=0 / false -> fuerza ventana visible (ej. ver un test).
bool headless = false;
ThemeMode theme = ThemeMode::FnDark; // Identidad visual unificada por defecto
float bg_r = 0.102f; // fn_tokens::colors::bg (dark.7 #1A1B1E)
float bg_g = 0.106f;
BIN
View File
Binary file not shown.
@@ -0,0 +1,199 @@
---
id: "0171"
title: "Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea"
status: pendiente
type: enhancement
domain:
- registry-quality
- infra
scope: registry-only
priority: alta
depends: []
blocks: []
related: ["0166"]
created: 2026-06-10
updated: 2026-06-10
tags: [projects, subrepo, gitea, clone, backup, manifest, fn-doctor]
---
> **Actualización 10/06/2026 — implementado el núcleo (enfoque KISS).** El manifest
> `subrepos.yaml` propuesto abajo se **descartó**: `registry.db` (tablas `apps`/`analysis`
> con `project_id`, propagadas entre PCs por `fn sync`) **ya es** el manifest de sub-repos, y
> `clone_project_subrepos_bash_pipelines` ya lo consume. No hace falta un archivo nuevo. Lo que
> faltaba era integración + auditoría. Ver `## Estado de implementación` al final.
# 0171 — Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0171 |
| **Estado** | pendiente |
| **Prioridad** | alta (riesgo de pérdida de datos) |
| **Tipo** | enhancement — metadata de projects + `/full-git-pull` + `fn doctor` |
## Contexto
El 10/06/2026, al preparar un dashboard sobre el project `aurgi`, se descubrió que el project
paraguas **no existía en Gitea** (`dataforge/aurgi` → 404). Sus 3 analyses sí estaban a salvo como
sub-repos independientes (`dataforge/venta_web`, `dataforge/sale_prices_comprobation`,
`dataforge/presupuestos_callcenter`), pero **el `project.md`, `vault.yaml` y `CONVENTIONS.md` de
nivel-project no estaban versionados en ningún sitio**. Reconstruir el project obligó a *adivinar*
los nombres de los sub-repos hijos uno a uno desde la lista completa de repos de Gitea.
Una auditoría de cobertura `projects ↔ Gitea` confirmó el agujero:
| Project | Repo Gitea | Riesgo |
|---|---|---|
| fleet_monitoring, fn_monitoring, message_bus, web_scraping | ✅ | ninguno |
| **obsidian**, **osint** | ❌ (solo en disco local) | alto — resuelto en esta sesión (subidos a `dataforge/obsidian`, `dataforge/osint`) |
| **aurgi** | ❌ (404, paraguas inexistente) | pendiente — analyses salvados, docs nivel-project no |
Dos problemas estructurales quedan abiertos:
1. **Projects sin repo Gitea**: su contenido de nivel-project vive solo en disco. Si se borra el
disco (o el project no se sincroniza a otro PC), se pierde. La regla `projects.md` dice que cada
project debe ser su propio repo Gitea, pero no hay nada que lo **verifique ni lo fuerce**.
2. **Sub-repos hijos no referenciados**: el `.gitignore` de cada project excluye `apps/*/` y
`analysis/*/` (son sub-repos independientes). Por tanto, **un clon fresco del project NO trae sus
hijos**, y no existe ningún manifest que diga *qué hijos clonar*. Hoy `/full-git-pull` solo
descubre repos vía `discover_git_repos_bash_infra` (busca `.git` ya presentes en disco): si el
hijo nunca se clonó, es invisible. Resultado: para reconstruir un project en una máquina nueva hay
que adivinar sus sub-repos (exactamente lo que pasó con aurgi).
## Objetivo
Que **todo project** (a) tenga su repo Gitea garantizado y (b) **referencie declarativamente sus
sub-repos hijos** (apps + analyses), de modo que clonar el project en cualquier PC permita
re-clonar automáticamente todo su árbol sin adivinar nada.
## Propuesta
### 1. Manifest de sub-repos por project
Añadir a cada project un manifest declarativo de sus hijos. Dos opciones de formato (decidir una):
- **Opción A (KISS, preferida): `subrepos.yaml`** en la raíz del project, análogo a `vault.yaml`:
```yaml
# projects/<p>/subrepos.yaml — sub-repos hijos de este project (apps + analyses)
subrepos:
- kind: analysis # app | analysis
name: venta_web
path: analysis/venta_web
repo: dataforge/venta_web
url: https://gitea-.../dataforge/venta_web
- kind: analysis
name: sale_prices_comprobation
path: analysis/sale_prices_comprobation
repo: dataforge/sale_prices_comprobation
url: https://gitea-.../dataforge/sale_prices_comprobation
```
- **Opción B: sección `## Sub-repos`** en `project.md` con una tabla `kind | name | path | url`.
`subrepos.yaml` (Opción A) es más fácil de parsear por las funciones de git y se versiona con el
project (no está en el `.gitignore`). El manifest se **autogenera/actualiza** escaneando los `.git`
hijos presentes en disco + su `remote get-url origin` (reusar `discover_git_repos_bash_infra`).
### 2. Generación y mantenimiento del manifest
Función/pipeline nueva (delegar a `fn-constructor`, grupo `infra`/git) que, dado un project:
- Escanea `apps/*/.git` y `analysis/*/.git`, lee su remote origin.
- Escribe/actualiza `subrepos.yaml`.
- Idempotente. Se invoca dentro de `/full-git-push` (o `fn index`) para mantener el manifest al día.
### 3. Re-clonado desde el manifest en `/full-git-pull`
Extender `/full-git-pull` para que, tras actualizar cada project, lea su `subrepos.yaml` y **clone
los hijos que falten** (`url``path`). Así, en un PC nuevo: clonar `dataforge/<project>`
`/full-git-pull` → reconstruye apps + analyses automáticamente. Requiere una función
`clone_missing_subrepos_bash_infra(project_dir)` (delegar a `fn-constructor`).
### 4. Garantizar repo Gitea de cada project + auditoría en `fn doctor`
- Subcomando nuevo `fn doctor projects` (función `audit_projects_coverage_go_infra`): por cada
project en disco reporta `repo_gitea` (existe en Gitea sí/no), `repo_url` (declarado en project.md
sí/no), y `subrepos_manifest` (presente + cuántos hijos en disco sin entrada / en manifest sin
clonar). Salida `--json`. Cero hallazgos = sano.
- Acción derivada documentada: `repo_gitea=no` → `ensure_repo_synced_bash_infra projects/<p>
dataforge <p> master "init: project <p>"`.
### 5. Backfill inicial
- `aurgi`: traer su `project.md` / `vault.yaml` / `CONVENTIONS.md` de `aurgi-pc` (o `home-wsl`) y
crear `dataforge/aurgi` + `subrepos.yaml` con los 3 analyses ya conocidos. **No** reconstruir a
mano un `project.md` mínimo (divergiría del real).
- Resto de projects con hijos (`fleet_monitoring`, `fn_monitoring`, `message_bus`, `web_scraping`):
generar su `subrepos.yaml` con la función del punto 2.
## Definition of Done
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: clon fresco reconstruye árbol | e2e | clonar `dataforge/<p>` en dir limpio → `/full-git-pull` | apps + analyses del project re-clonados desde `subrepos.yaml` |
| Edge: project sin hijos (obsidian) | e2e | generar manifest | `subrepos.yaml` válido y vacío (o ausente), sin error |
| Edge: hijo en disco sin `.git` | unit | auditoría | `fn doctor projects` lo reporta como "hijo sin sub-repo" |
| Error: project sin repo Gitea | e2e | `fn doctor projects --json` | lo marca `repo_gitea=false`, sugiere `ensure_repo_synced` |
| Cobertura | audit | `fn doctor projects` | 0 projects sin repo, 0 hijos sin referenciar |
## Decisiones abiertas
1. **Formato del manifest**: `subrepos.yaml` (A) vs. sección en `project.md` (B). Recomendado A.
2. **¿Auto-generar el manifest en `fn index`** o solo en `/full-git-push`? (evitar I/O de red en
`fn index`; preferible en push).
3. **aurgi**: ¿traer de `aurgi-pc` por SSH ahora, o dejarlo para cuando el project se sincronice?
## Notas
En esta sesión ya se resolvió el riesgo inmediato: `obsidian` y `osint` se subieron a Gitea
(`dataforge/obsidian`, `dataforge/osint`) con `ensure_repo_synced_bash_infra` y se les añadió
`repo_url` en su `project.md`. Este issue cubre la solución **estructural y reutilizable** para que
el caso no vuelva a ocurrir con ningún project. Relacionado con #0166 (dependencias app→app para
build reproducible): ambos persiguen que clonar el ecosistema en un PC nuevo sea determinista.
## Estado de implementación (10/06/2026)
Implementado con enfoque KISS, **sin** `subrepos.yaml` (registry.db + `fn sync` ya cumplen esa
función). Cambios:
**Funciones nuevas:**
- `ensure_project_gitignore_bash_infra` — garantiza idempotente el `.gitignore` canónico de un
project (`apps/*/`, `analysis/*/`, `vaults/*` + excepciones) antes de cualquier `git add -A`,
para no trackear el contenido de los sub-repos hijos.
- `audit_projects_coverage_go_infra` (+ `FormatProjectsCoverage`) — motor de `fn doctor projects`.
Reporta por project: `git`/`remote`/`repo_url`/`children (cloned/inDB)` + issues
(`no_gitea_repo`, `children_missing`, `dir_not_found`). Solo git local + registry.db, sin red.
**Integraciones:**
- `full_git_push` v1.1.0 — paso 1c: auto-inicializa y pushea los **projects paraguas** sin repo
(antes solo apps/analyses), asegurando el `.gitignore` canónico primero. Cierra el agujero
aurgi/obsidian/osint.
- `full_git_pull` v1.1.0 — paso 6: tras `fn sync`, reclona los sub-repos hijos faltantes de cada
project con `clone_project_subrepos` + re-index. Clonar el paraguas + `/full-git-pull` reconstruye
el árbol entero.
- `fn doctor projects` — nuevo subcomando (`cmd/fn/doctor.go`). Hoy reporta **0 projects con
problemas**.
**Hecho aparte (riesgo inmediato):** `dataforge/obsidian` + `dataforge/osint` creados, `repo_url`
en sus `project.md`.
### Pendientes (no bloquean el núcleo)
1. **Check inverso — HECHO (10/06/2026).** `FindOrphanProjectRefs` + `FormatOrphanProjectRefs` en
`audit_projects_coverage_go_infra`, enchufado en `fn doctor projects`. Detecta apps/analysis con
`project_id` sin fila en `projects`. Hoy reporta 4 paraguas huérfanos (existen en otro PC, nunca
subidos a Gitea — mismo caso que aurgi):
- `element_agents` (6 apps: agents_and_robots, agents_dashboard, device_agent, element_matrix_chat,
matrix_admin_panel, matrix_client_pc)
- `imagegen` (image_to_3d_studio)
- `osint_graph` (graph_explorer)
- `aurgi` (sus analyses sí están en Gitea; el paraguas no)
2. **Fix de datos de los 4 paraguas huérfanos — pendiente, requiere el PC origen.** No están en disco
ni en Gitea en este PC (`lucas-linux`), así que no se pueden reconstruir aquí sin inventar. El fix
correcto: correr `/full-git-push` en el PC donde cada paraguas existe en disco (`aurgi-pc` /
`home-wsl`). Con `full_git_push` v1.1.0 (paso 1c) eso ya los crea en Gitea automáticamente. Tras
eso, `/full-git-pull` aquí (paso 6) los traerá. NO reconstruir un `project.md` mínimo a mano.
3. **DoD vida útil**: validar el reclonado en un PC nuevo real (clon limpio del paraguas →
`/full-git-pull` → árbol reconstruido) antes de declarar el issue cerrado.
+184
View File
@@ -0,0 +1,184 @@
---
id: "0172"
title: "App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes sobre el vault osint"
status: pendiente
type: app
domain:
- osint
- frontend
scope: app-scoped
priority: media
depends: []
blocks: []
related: ["0171"]
created: 2026-06-10
updated: 2026-06-10
tags: [osint, web, sigma, graph, mantine, obsidian, vault, dashboard]
---
# 0172 — App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0172 |
| **Estado** | pendiente (solo plan — se construye cuando el vault tenga más datos) |
| **Prioridad** | media |
| **Tipo** | app — nueva app web en `projects/osint/apps/osint_web` |
| **Project** | osint (`projects/osint/`) |
## Contexto
El project `osint` guarda sus investigaciones en el vault de Obsidian
`/home/enmanuel/Obsidian/osint` (sub-repo `dataforge/osint`). Hoy ese vault tiene:
- **~82 nodos** repartidos en carpetas tipadas: `personas/` (45), `organizaciones/` (25),
`lugares/` (10), `dominios/` (1), `casos/` (1).
- **Datos tabulares** en el frontmatter YAML de cada ficha: `tipo`, `nombre`, `sexo`,
`fecha_nacimiento`, `dni`, `direccion`, `pais`, `aliases`, `tags`, etc.
- **Aristas implícitas**: los wikilinks `[[...]]` en las secciones `Relaciones`, `Lugares` y
`Documentos` conectan unas fichas con otras (y con sus attachments).
- **~240 attachments**: fotos, DNIs, certificados y PDFs en `attachments/<tipo>/<slug>/`,
embebidos en las notas con `![[...]]`.
Obsidian es bueno para *escribir* la investigación, pero malo para *explorarla* de un vistazo:
no da un grafo navegable de todos los objetivos, ni una tabla filtrable, ni una ficha-resumen
con la galería de imágenes de cada persona. Metabase/Grafana no encajan: leen BD SQL (no `.md`),
y no muestran ni grafo de nodos ni imágenes inline.
Decisión del usuario (10/06/2026): construir una **app web propia** que lea el vault y ofrezca
tres vistas — **grafo explorable con sigma.js**, **tablas filtradas por tipo**, y **fichas con
imágenes**. Este issue es **solo el plan**: la recopilación de datos en Obsidian continúa primero;
la app se implementa cuando haya suficiente material que justifique la inversión.
## Objetivo
Una app web local que, leyendo directamente los `.md` del vault `osint` (sin BD intermedia
obligatoria en v1), permita:
1. **Explorar el grafo** de nodos (personas, organizaciones, lugares, dominios, casos) y sus
conexiones por wikilinks, con sigma.js: zoom, pan, click en nodo → ficha, colores por tipo,
filtro de tipos visibles, búsqueda de nodo.
2. **Ver tablas filtradas por tipo**: una tabla por categoría (personas, organizaciones, ...)
con las columnas del frontmatter, ordenable y filtrable (por dni, lugar, fecha, tag).
3. **Abrir la ficha** de cualquier nodo: frontmatter renderizado + cuerpo Markdown + galería de
sus attachments (fotos, DNIs, PDFs) servidos por el backend.
## Arquitectura propuesta
```
projects/osint/apps/osint_web/ (sub-repo Gitea dataforge/osint_web)
app.md frontmatter de registro (framework: react-vite-mantine)
server/ backend Python (lee el vault, sirve JSON + attachments)
main.py FastAPI o stdlib http
frontend/ React + Vite + Mantine + sigma.js
src/
views/GraphView.tsx sigma.js + graphology
views/TablesView.tsx Mantine DataTable filtrable por tipo
views/NodeCard.tsx ficha + galería de attachments
```
### Backend (Python — máximo reuso del grupo `obsidian`)
Python porque el grupo de capacidad `obsidian` (11 funciones, dominio `obsidian`) ya cubre casi
todo el parseo del vault. **Registry-first**: el backend orquesta estas funciones, no reimplementa
el parseo.
Funciones del registry a reutilizar:
| Función | Uso en la app |
|---|---|
| `list_obsidian_notes_py_obsidian` | enumerar nodos por carpeta/tipo |
| `read_obsidian_note_py_obsidian` | leer ficha: `{frontmatter, body, wikilinks, tags}` |
| `parse_obsidian_frontmatter_py_obsidian` | datos tabulares de cada nodo |
| `extract_obsidian_wikilinks_py_obsidian` | aristas del grafo |
| `extract_obsidian_embeds_py_obsidian` | attachments embebidos en cada nota |
| `resolve_obsidian_embed_py_obsidian` | resolver `![[foto.jpg]]` → path real en disco para servir la imagen |
| `slugify_obsidian_name_py_obsidian` | normalizar nombre de wikilink → id de nodo |
| `search_obsidian_notes_py_obsidian` | búsqueda global en el grafo |
Funciones **nuevas** a delegar a `fn-constructor` (no escribir inline en la app):
- `build_obsidian_graph_py_obsidian` (impure) — dado `vault_dir`, devuelve
`{"nodes": [{id, tipo, label, frontmatter}], "edges": [{source, target, kind}]}`.
Resuelve cada wikilink a un nodo existente (vía slug / nombre de archivo); los wikilinks que
no resuelven a un `.md` del vault se marcan como aristas "dangling" o se descartan según flag.
Tag de grupo: `obsidian`. Es la pieza que el grupo declara como frontera no cubierta
("No indexa el grafo agregado") — esta función la cierra.
Endpoints HTTP (JSON salvo el de attachments):
| Método | Ruta | Devuelve |
|---|---|---|
| GET | `/api/graph` | grafo completo `{nodes, edges}` para sigma.js |
| GET | `/api/nodes?tipo=persona` | filas de la tabla de ese tipo (frontmatter aplanado) |
| GET | `/api/node/{slug}` | ficha: frontmatter + body (HTML/markdown) + lista de attachments |
| GET | `/api/attachment?path=...` | sirve el binario del attachment (image/pdf), con allowlist al vault |
| GET | `/api/search?q=...` | nodos que matchean |
Seguridad: el backend solo sirve archivos **dentro** del vault osint (path traversal bloqueado).
El vault contiene datos personales sensibles (DNIs) → la app escucha **solo en `127.0.0.1`**, sin
exponer a red. No es un service desplegable a VPS.
### Frontend (React + Vite + Mantine + sigma.js)
- Sistema del registry: React + Vite + Mantine v9 + `@fn_library` (grupo `mantine`, 63 funciones).
Componentes propios de `@fn_library` antes que HTML nativo (regla `frontend_theming.md`).
- **Grafo**: `sigma.js` + `graphology`. Color por `tipo`, tamaño por grado, layout
force-directed (graphology-layout-forceatlas2). Click en nodo → abre `NodeCard`. Panel lateral
con toggles de tipos visibles y caja de búsqueda.
- **Tablas**: una pestaña por tipo, Mantine `Table`/DataTable con columnas del frontmatter,
orden y filtro por columna (dni, lugar, fecha_nacimiento, tags).
- **Fichas**: `NodeCard` con frontmatter en formato clave-valor (fechas en formato europeo
DD/MM/AAAA — memoria `formato-fecha-europeo`), cuerpo Markdown, y galería de attachments
(imágenes con lightbox; PDFs como enlace/embed).
`sigma.js` y `graphology` son dependencias nuevas del frontend (no en `@fn_library`). KISS:
añadir solo esas dos; el resto (tabla, layout, modales) sale de Mantine/`@fn_library`.
## Decisiones abiertas
1. **¿BD intermedia o lectura directa del vault?** v1 lee el vault en cada arranque (cachea el
grafo en memoria). Si el vault crece mucho o se quiere histórico/diff, evaluar un
`operations.db` con `entities`/`relations` (encaja con el bucle reactivo). Recomendado:
empezar sin BD (KISS), añadirla solo si el rendimiento o un caso de uso lo exige.
2. **Backend FastAPI vs stdlib http**: FastAPI da validación y OpenAPI gratis; stdlib evita una
dependencia. Como el backend es fino (orquesta funciones del registry), decidir al construir.
3. **Live-reload del vault**: ¿re-escanear bajo demanda (botón "refrescar") o watcher de
filesystem? v1: botón refrescar (simple). Watcher si molesta.
4. **Aristas dangling**: wikilinks a notas que aún no existen — ¿mostrarlos como nodos fantasma
(útil para ver "objetivos pendientes de fichar") o esconderlos? Propuesta: nodo fantasma con
estilo atenuado, toggle para ocultar.
## Definition of Done
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: grafo carga el vault | e2e | `GET /api/graph` con el vault osint real | `nodes` ≥ nº de `.md`, `edges` con los wikilinks resueltos; sigma.js los pinta |
| Golden: ficha con imágenes | e2e | `GET /api/node/<persona con fotos>` + abrir NodeCard | frontmatter + cuerpo + galería con las imágenes de `attachments/personas/<slug>/` |
| Edge: tabla filtrada por tipo | e2e | `GET /api/nodes?tipo=organizacion` | solo nodos de ese tipo, columnas del frontmatter |
| Edge: wikilink dangling | unit | nota con `[[Persona-Inexistente]]` | arista marcada dangling / nodo fantasma, sin crash |
| Edge: nombre con mayúsculas/acentos | unit | wikilink `[[María del Mar]]` → slug | resuelve a `maria-del-mar-...md` vía `slugify_obsidian_name` |
| Error: path traversal en attachment | e2e | `GET /api/attachment?path=../../etc/passwd` | 403/404, jamás sirve fuera del vault |
| Error: vault inexistente | e2e | arrancar con `--vault /no/existe` | error claro al arrancar, no 500 silencioso |
| Cobertura | audit | `uses_functions` del `app.md` | declara todas las funciones del grupo `obsidian` consumidas |
Vida útil (cuando se construya): usar la app de verdad sobre el vault osint durante ≥7 días en
investigaciones reales; medir que el grafo sigue cargando sin romperse al crecer el vault.
## Notas
**Estado actual: solo plan.** No construir todavía — la recopilación de datos en Obsidian
continúa; cuando el vault tenga masa crítica de objetivos/relaciones, se arranca con
`/new-cpp-app` no aplica (es web): se hace `git init` del sub-repo `dataforge/osint_web` dentro de
`projects/osint/apps/osint_web/` antes de limpiar cualquier worktree (regla `apps_subrepo.md`),
scaffolding de frontend con el stack Mantine del registry, y backend Python orquestando el grupo
`obsidian`.
Onboarding (para cuando exista): arrancar backend `python server/main.py --vault
/home/enmanuel/Obsidian/osint --port 8470` y `pnpm dev` en `frontend/`; abrir
`http://127.0.0.1:5173`. Pestañas: Grafo / Tablas / (ficha al click). Solo localhost por los
datos sensibles del vault.
Relación con #0171 (manifest de sub-repos): cuando esta app exista será un hijo del project
`osint` y debe entrar en su `subrepos.yaml` para re-clonarse en otros PCs.
@@ -0,0 +1,162 @@
---
id: "0167"
title: "fn run de library function Go ejecuta go test del paquete entero (arrastra tests flaky vecinos)"
status: completado
type: enhancement
domain:
- registry-quality
scope: registry-only
priority: media
depends: []
blocks: []
related: ["0077"]
created: 2026-06-03
updated: 2026-06-03
tags: [fn-run, go, testing, flaky, dag-engine, reliability]
---
# 0167 — fn run de library function Go ejecuta go test del paquete entero
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0167 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | enhancement — dispatcher de `fn run` |
## Contexto
Cuando `fn run <id>` recibe una **library function Go sin `main.go`** que tiene tests
declarados (`tested: true` + `test_file_path`), el dispatcher (`cmd/fn/run.go:171-181`)
ejecuta:
```
go test -v -count=1 -tags fts5 ./functions/<domain> # el PAQUETE ENTERO
```
Es decir, no ejecuta "la función" (no se puede: no tiene `main`), sino que corre **todos
los tests del paquete**. Consecuencia: el éxito de `fn run miFuncion` depende de que pasen
los tests de **todas las demás funciones del mismo paquete**, no solo los suyos.
### Cómo se manifestó
Los DAGs `daily-registry-audit` y `weekly-deep-scan` del `dag_engine` invocaban funciones
`*_go_infra` (`find_unused_functions`, `artefact_doctor`, etc.) como `function:` steps.
Cada step disparaba `go test ./functions/infra` (paquete completo), que contiene tests
impuros con recursos fijos:
- `TestSSHTunnelOpenClose``bind [127.0.0.1]:19876: Address already in use`
- `TestDockerContainerExec``listen unix .../docker_exec_test.sock: bind: invalid argument` (path de socket > 108 chars con TMPDIR largo)
Al correr dos `function:` steps en paralelo (ambos `depends` del mismo padre), las dos
invocaciones de `go test ./functions/infra` colisionaban en el **puerto fijo 19876**
una pasaba y la otra fallaba de forma no determinista. Resultado: el DAG fallaba sin
auditar nada, y el fallo parecía "la auditoría encontró un problema" cuando en realidad
era un test de red vecino.
> Nota: el síntoma operativo en los DAGs ya se resolvió por otra vía (2026-06-03): los
> steps ahora usan `audit_doctor_snapshot_bash_infra` (Bash), que ejecuta `fn doctor <sub>`
> real en vez de `go test` del paquete. Este issue es la **causa raíz general** del
> dispatcher, que sigue afectando a cualquier `fn run <library_go_fn_con_tests>`.
## Problema
1. `fn run` de una library function NO ejecuta la función — corre el paquete de test entero.
2. Los tests impuros de un paquete (puertos/sockets/red fijos) no son seguros para
ejecuciones concurrentes ni reproducibles en cualquier entorno (TMPDIR, CI).
3. Un único test flaky en `functions/infra` rompe `fn run` de las ~N funciones testeadas
del paquete, y por extensión cualquier DAG/cron que las invoque.
## Opciones de solución (decidir en implementación)
### Opción A — library Go sin main → siempre compile-check (`go vet`/`go build`)
`fn run <lib_fn>` significa "verifica que la función va"; para código sin `main` eso es
"compila". Testear es responsabilidad de `go test` / CI, no de `fn run` en un cron.
- **Pro**: determinista, rápido, elimina el flaky de raíz.
- **Contra**: rompe el comportamiento documentado en `CLAUDE.md` ("`fn run filter_slice_go_core`
→ Go function con tests → `go test -v`"). Perderíamos la capacidad de correr los tests de
una función vía `fn run`.
### Opción B — go test acotado con `-run` a los tests de la función
Si la función declara sus tests, ejecutar solo esos:
```
go test -v -count=1 -tags fts5 -run '^(TestX|TestY)$' ./functions/<domain>
```
- **Pro**: aísla del flaky vecino manteniendo "fn run corre mis tests".
- **Contra / RIESGO**: si los nombres de `fn.Tests` (frontmatter YAML, `registry/parser.go:32`)
tienen **drift** respecto al código, `-run` no matchea y `go test` sale 0 con
"no tests to run" → **falso-verde** en una primitiva crítica de todo el ecosistema.
Mitigación obligatoria si se elige B: reconciliar `fn.Tests` con los tests extraídos por
el indexer (`registry/test_parser.go::parseGoTests`, que ya puebla `unit_tests`) y/o
detectar "0 tests ejecutados" parseando el output y tratarlo como fallo.
### Opción C — aislar los tests impuros del paquete
Hacer robustos los tests culpables: puerto efímero (`:0` en vez de `19876`), socket en path
corto bajo `/tmp` con nombre acotado, `t.Parallel`-safe. No cambia el dispatcher pero reduce
la probabilidad de colisión.
- **Pro**: no toca `fn run` (cero blast radius sistémico).
- **Contra**: no resuelve el problema conceptual (sigue corriendo el paquete entero); otros
paquetes pueden introducir tests impuros nuevos y reincidir.
## Recomendación
Combinar **C** (saneamiento inmediato de `TestSSHTunnelOpenClose` y `TestDockerContainerExec`,
bajo riesgo) con **B** endurecida (acotar `-run` + guard anti-falso-verde apoyado en
`unit_tests` extraídos, no en el frontmatter manual). La Opción A es la más limpia
conceptualmente pero rompe comportamiento documentado; evaluar si ese comportamiento
("fn run corre los tests") aún se usa de verdad o puede deprecarse hacia `go test` directo.
## Definition of Done
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: `fn run` de library fn testeada | e2e | `./fn run find_unused_functions_go_infra` | exit 0 sin depender de tests de funciones vecinas |
| Edge: dos `fn run` concurrentes del mismo paquete | e2e | dos invocaciones en paralelo de funciones de `functions/infra` | ambas exit 0, sin colisión de puerto/socket |
| Error: nombres de test con drift (si se elige B) | unit | `fn.Tests` con un nombre inexistente | NO produce falso-verde (se detecta "0 tests run" → fallo) |
| Tests impuros saneados | unit | `go test -run 'TestSSHTunnelOpenClose\|TestDockerContainerExec' ./functions/infra` repetido 5× | 5/5 PASS deterministas |
## Resolución (2026-06-03)
Implementada la combinación **C + B** recomendada.
### C — Tests impuros saneados (`functions/infra/`)
- `ssh_tunnel_test.go`: el puerto fijo `19876` pasa a **puerto efímero** (`freeTCPPort` pide `:0` al kernel). Elimina el `bind: address already in use` bajo concurrencia.
- `docker_container_exec_test.go`: el socket Unix deja de colgar de `t.TempDir()` (path largo con el nombre del subtest) y usa un **directorio corto** bajo `/tmp` (`os.MkdirTemp("/tmp", "dk")` + cleanup). Elimina el `bind: invalid argument` por exceder los ~108 bytes de `sun_path`.
- Verificado: `go test -run 'TestSSHTunnelOpenClose|TestDockerContainerExec' -count=5 ./functions/infra/``ok` (5×, determinista).
### B — `fn run` acota los tests a la función (`cmd/fn/run.go`)
- Para una library Go function con tests, el dispatcher ahora añade
`-run '^(<tests>)$'` con los nombres **extraídos por el indexer** (`unit_tests`,
vía `db.GetUnitTestsByFunction`), no los del frontmatter `.md` (que pueden driftar).
Así `fn run` ejecuta solo los tests de esa función, aislándola de tests flaky de
funciones vecinas del mismo paquete. Si no hay nombres extraídos, cae al paquete
entero (comportamiento previo).
- **Guard anti-falso-verde**: `cmdRun` refleja el output de un `go test -run` a un
buffer; si go test reporta `no tests to run` (que sale con exit 0), el run se trata
como **fallo** (exit 1 + mensaje pidiendo `fn index`). Evita que un drift de nombres
produzca un verde silencioso.
### Evidencia (DoD)
| Escenario | Resultado |
|---|---|
| Golden: `fn run find_unused_functions_go_infra` | Corre solo sus 2 tests (`TestFindUnusedFunctions_*`) en 0.06s, exit 0. No toca SSH/Docker. |
| Edge concurrente: 2 `fn run` del paquete `infra` en paralelo | Ambos exit 0, sin colisión de puerto. |
| Error/drift: `unit_tests` con nombre inexistente | `go test` da `[no tests to run]`; el guard lo intercepta → exit 1 con mensaje. NO falso-verde. |
| Tests saneados 5× | `ok` determinista. |
`go vet ./cmd/fn/` y `go test ./cmd/fn/` verdes tras los cambios.
## Notas
- Archivos clave: `cmd/fn/run.go` (dispatcher, líneas 145-194), `registry/parser.go`
(campo `Tests`), `registry/test_parser.go` (extracción de nombres de test),
`functions/infra/ssh_tunnel_open_close_test.go` y `functions/infra/docker_container_exec_test.go`
(tests culpables).
- Relacionado con 0077 (fn-run-bash-output-mudo): familia de issues sobre la semántica y
observabilidad de `fn run`.
+2
View File
@@ -9,6 +9,8 @@ Registry personal de código con búsqueda FTS. Diseñado para composición func
- `integrity.md` — Reglas de integridad y referencias cruzadas
- `architecture.md` — Visión general del sistema
- `sync_setup.md` — Vincular una PC al server `registry.organic-machine.com` (env vars, `fn sync`, troubleshooting)
- `adr/` — Architecture Decision Records: decisiones de diseño (qué se decidió y por qué)
- `../reports/` — Reportes de trabajo: **artefacto local** (entregable de una tarea: qué se hizo, cómo se verificó, gaps). Gitignored salvo `.gitkeep`, NO sube a Gitea ni se versiona (como los vaults). Convención en `.claude/rules/reports.md`. Decisión: [ADR 0006](adr/0006-reports-folder.md)
## Tablas
+71
View File
@@ -0,0 +1,71 @@
# ADR 0005 — Mantener el `.git` del repo padre ligero: no trackear artefactos hijos, purgar basura del historial, submódulos shallow
- **Fecha:** 2026-06-03
- **Estado:** accepted
## Contexto
El `.git` del repo padre `fn_registry` había crecido a **475 MB**, un tamaño que ralentiza clones, `fn sync` y la operación diaria entre los tres PCs del ecosistema (`aurgi-pc`, `home-wsl`, `lucas-linux`). El diagnóstico identificó tres causas independientes, todas evitables:
1. **Artefactos hijos forzados al índice.** Pese a que el `.gitignore` ya tiene las reglas correctas (`apps/*/`, `analysis/*/`, `projects/*/`), un `.gitignore` no des-trackea archivos que ya estaban en el índice. Dos apps tenían contenido forzado: `apps/dag_engine/` (31 archivos: código Go + frontend + `app.md` + `README.md`) y `apps/shaders_lab/` (`app.md` + un binario `shaders_lab.exe`). El commit `d8db05e9` ("chore(dag_engine): app.md ... metadata trackeada por el padre") había trackeado dag_engine deliberadamente; esta decisión queda **anulada**. La convención correcta ya estaba demostrada por `projects/*/apps` (p.ej. `registry_dashboard`, `call_monitor`), que el padre no versiona en absoluto y funcionan bien.
2. **Basura en el historial.** Versiones antiguas de directorios que nunca debieron versionarse seguían viviendo en los commits pasados: `frontend/node_modules` (168 MB de binarios), `build/` raíz (54 MB de artefactos de compilación C++, 2299 archivos), `registry.db` (29 MB en ~7 versiones; regenerable con `fn index`) y `apps/shaders_lab/shaders_lab.exe` (~190 MB acumulados en ~10 versiones). En total ~440 MB de blobs muertos.
3. **Submódulos C++ con historia completa.** `cpp/vendor/{imgui,implot,implot3d,tracy,glfw,sdl3}` + `emsdk` son submódulos git legítimos (deps necesarias para compilar las apps imgui). Cada uno clonaba **toda la historia upstream**: `.git/modules` pesaba 338 MB para servir un working tree de 118 MB. imgui solo: 129 MB de `.git` (11.552 commits) para 8.9 MB de headers; sdl3: 146 MB (21.539 commits) para 55 MB de código. El proyecto compila contra **un único commit pinneado** por submódulo — el resto es historia ajena que nadie consulta.
## Decisión
Mantener el `.git` del padre ligero con tres medidas:
1. **El repo padre NO versiona el contenido de los artefactos hijos.** Cada app/analysis/project-app es un sub-repo Gitea independiente con su propio `.git` (ADR 0002); el padre solo conserva su metadata en `registry.db` (regenerable con `fn index`, que lee los artefactos del disco). Se sacan del índice con `git rm -r --cached` (con `--cached` SIEMPRE — sin él se borraría el working tree de los sub-repos). Único contenido versionado bajo `apps/` y `analysis/`: los marcadores `.gitkeep`. Bajo `projects/`: solo los `project.md`.
2. **El historial pasado se purga de basura con `git filter-repo`.** Se eliminan los blobs de `frontend/node_modules`, `build/` (raíz), `registry.db` y `apps/shaders_lab/shaders_lab.exe` de todos los commits. Esto reescribe la historia (cambian los SHAs) y requiere `git push --force`. Se añade `build/` (raíz) al `.gitignore` para evitar reincidencia (`node_modules`, `*.exe` y `registry.db` ya estaban).
3. **Los submódulos C++ se configuran shallow (`depth 1`).** Cada submódulo descarga solo el commit pinneado, no la historia upstream. Se marca `shallow = true` en `.gitmodules` para que los clones futuros nazcan shallow. El working tree mantiene el snapshot completo de cada dependencia, así que la compilación C++ no cambia.
## Cómo se ejecutó (2026-06-03)
```bash
# 1. Untrack del índice (los archivos quedan en disco; los .git de los sub-repos conservan el código)
git rm -r --cached apps/dag_engine apps/shaders_lab
git commit -m "chore: untrack contenido de artefactos hijos (dag_engine, shaders_lab)"
# 2. Purga del historial (con git-filter-repo, descargado standalone)
python3 git-filter-repo --strip-blobs-bigger-than 10M --force
python3 git-filter-repo --invert-paths --path frontend/node_modules --path build \
--path registry.db --path apps/dag_engine --path apps/shaders_lab --force
git remote add origin <url> # filter-repo elimina el remote por seguridad
git push --force origin master
# 3. Submódulos shallow (deinit + borrar el .git/modules full + re-clone --depth 1)
for sm in cpp/vendor/{sdl3,imgui,tracy,glfw,implot,implot3d} emsdk; do
git submodule deinit -f "$sm"
rm -rf ".git/modules/$sm" # clave: deinit NO borra .git/modules
git -c "submodule.$sm.shallow=true" submodule update --init --depth 1 "$sm"
done
sed -i '/^\turl = /a\\tshallow = true' .gitmodules
git commit -m "chore: submodulos C++ en modo shallow (depth 1)" && git push origin master
```
Resultado: `.git` **475 MB → 51 MB** (89%). Desglose: `.git/objects` 137 MB → 16 MB (historial del registry limpio); `.git/modules` 338 MB → 35 MB (submódulos shallow). `cpp/vendor` en disco intacto (118 MB). `cmake configure` de `cpp/` OK con las deps shallow. Backup completo del `.git` pre-purga en `~/backups/fn_registry_purge_20260603/`.
## Alternativas descartadas
- **Solo `git rm --cached` sin purgar el historial.** Detiene el crecimiento futuro pero deja los ~440 MB de basura en los commits pasados. No reduce el `.git`. Insuficiente para el objetivo.
- **Purgar solo `shaders_lab.exe`.** Mismo coste (force-push + re-clone en otros PCs) por mucha menos ganancia: deja `node_modules`, `build/` y `registry.db` en el historial.
- **Borrar o purgar los submódulos C++.** Son deps legítimas necesarias para compilar las apps imgui. Purgarlas rompería la compilación. La vía correcta es shallow, no eliminación.
- **`git filter-branch` en vez de `git-filter-repo`.** Más lento, deja `refs/original` y es propenso a errores. `filter-repo` es la herramienta recomendada por el propio git.
## Consecuencias
- **Force-push reescribe la historia del padre.** Los otros PCs (`aurgi-pc`, `home-wsl`) quedan con historia divergente y deben re-sincronizar: `git fetch origin && git reset --hard origin/master && git submodule update --init --recursive`. Trabajo local del padre sin pushear se pierde — verificar antes. Esta es la única parte irreversible y outward-facing; requiere confirmación humana explícita.
- **Shallow es local y reversible.** No toca el repo padre ni los gitlinks; solo adelgaza el `.git` interno de cada submódulo. Reversible por dep con `git fetch --unshallow`. El `.gitmodules` con `shallow = true` hace que los clones frescos nazcan ligeros; los clones existentes deben re-aplicar el `deinit + rm .git/modules/<x> + update --depth 1` o re-clonar.
- **Bumpear una dep shallow cuesta un `git fetch --depth 1 <commit>` extra** antes del checkout, porque el commit nuevo no está en el clon mínimo. Es fricción, no bloqueo.
- **Se pierde `git log/blame/bisect` dentro de los submódulos** (la historia de SDL3/imgui), algo que casi nunca se hace en deps vendored.
- **Operación repetible.** Si el `.git` vuelve a crecer por basura, el procedimiento de este ADR (untrack + filter-repo + shallow) es el runbook.
## Relación con otras reglas y ADRs
- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses como sub-repos `dataforge/<name>`. Este ADR refuerza su corolario: el padre no versiona su contenido.
- `.claude/rules/apps_subrepo.md` — gotcha de pérdida de código en worktrees; misma raíz (artefactos = sub-repos independientes).
- `.claude/rules/db_locations.md``registry.db` solo en la raíz y regenerable; por eso es purgable del historial.
+53
View File
@@ -0,0 +1,53 @@
# ADR 0006 — `reports/` como artefacto local para reportes de trabajo
- **Fecha:** 2026-06-06
- **Estado:** accepted
## Contexto
Cuando un agente termina una tarea no trivial (una auditoría, una tanda de fixes con verificación, un refactor, una investigación), el resumen ejecutable —qué se hizo, cómo se verificó, qué quedó pendiente— vivía solo en el chat de la sesión. Eso tiene tres problemas:
1. **Se pierde**: el chat no es consultable después; el resumen no queda en disco.
2. **No es compartible rápido**: para pasar el resultado hay que copiar a mano del chat.
3. **No tiene formato estable**: cada resumen sale distinto, sin garantía de evidencia ejecutable ni de declaración honesta de gaps.
Los contenedores existentes no encajan: los ADRs (`docs/adr/`) son decisiones de diseño; las reglas (`.claude/rules/`) son normas operativas; el diario (`docs/diary/`) es bitácora cronológica libre. Faltaba un sitio para el **entregable de una tarea concreta**: el resultado y su evidencia.
Punto clave de la decisión: un report **no es documentación del registry, es un artefacto** (en el sentido de `.claude/rules/artefactos.md`) — generado, con ciclo de vida propio, no código reutilizable. Y como artefacto del tipo "datos locales", se comporta como los **vaults**: no sube a Gitea ni se versiona en el git del padre.
## Decisión
Crear la carpeta `reports/` para reportes de trabajo, tratados como **artefacto local**:
1. **No versionados, no Gitea.** `reports/*` está en el `.gitignore` del padre (solo `reports/.gitkeep` se versiona, para mantener la carpeta presente). Un report no tiene repo propio: vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`. Mismo trato que los vaults.
2. **Conviven en raíz o en proyectos**, como cualquier artefacto: `reports/` (sueltos) o `projects/<p>/reports/` (del trabajo de un proyecto). Ambas rutas gitignored (`reports/*`, `projects/*/reports/`). Se permiten subcarpetas para agrupar.
3. **No se indexan en `registry.db`.** Sin tabla `reports` ni schema (KISS) — son texto plano efímero, como los `playgrounds`.
4. **Convención y plantilla** viven en `.claude/rules/reports.md` (versionado): nombre `NNNN-YYYY-MM-DD-slug.md`, secciones Resumen/Cambios/Verificación/Gaps, evidencia ejecutable obligatoria.
Un report NO sustituye a un ADR ni a una regla: si durante el trabajo aparece una decisión de diseño, va a `docs/adr/` y el report solo la referencia.
## Alternativas consideradas
- **Versionar los reports en el repo padre.** Era el enfoque inicial de este ADR; descartado: un report es un artefacto (resultado de tarea, efímero, posiblemente voluminoso o ligado a un PC concreto), no documentación estable del registry. Versionarlos ensucia el historial del padre con entregables operativos. La convención correcta es la de los vaults: local, no Gitea.
- **Dejar los resúmenes solo en el chat.** Status quo; se pierden y no son compartibles. Es el motivo del ADR.
- **Usar `docs/diary/`.** El diario es cronológico, libre y versionado; mezclaría notas con entregables formales y no impone evidencia ejecutable.
- **Un ADR por tarea.** Sobrecarga el registro de decisiones con resultados operativos.
- **Indexar los reports en `registry.db`.** Añade schema y mantenimiento para un artefacto efímero. KISS: no se indexa, como los playgrounds.
## Consecuencias
- `.gitignore` del padre gana `reports/*` (con `!reports/.gitkeep`) y `projects/*/reports/`.
- Nueva regla `.claude/rules/reports.md` con convención + plantilla; entrada en `.claude/rules/INDEX.md`.
- `report` se añade como tipo de artefacto en `.claude/rules/artefactos.md` (NO indexado, NO sub-repo Gitea).
- Mención en la sección "Estructura" / "Artefactos" de `.claude/CLAUDE.md` y en `docs/README.md`.
- Los agentes pueden escribir un report al cerrar una tarea no trivial y pasar la ruta para compartir, en vez de volcar el resumen al chat. El report queda local (no viaja por git/`fn sync` salvo que el usuario lo copie aparte).
- Primer report: `projects/web_scraping/reports/0001-2026-06-06-browser-domain-audit-fixes.md` (local, gitignored; vive en el proyecto porque el trabajo tocó sus apps). Cada project que use reports añade `reports/*` (salvo `!reports/.gitkeep`) a su propio `.gitignore` para no subirlos a su Gitea.
## Relación con otras reglas y ADRs
- `.claude/rules/artefactos.md` — report es un tipo de artefacto; este ADR lo añade a la taxonomía.
- `.claude/rules/reports.md` — convención operativa derivada de este ADR.
- `.claude/rules/playgrounds.md` — mismo espíritu (artefacto local, no indexado).
- `.claude/rules/dod_quality.md` — los reports heredan su exigencia de evidencia ejecutable y gaps.
- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses SÍ son sub-repos Gitea; los reports NO (se parecen a los vaults, no a las apps).
- [ADR 0005](0005-keep-parent-git-lean.md) — mantener el `.git` del padre ligero; no versionar reports refuerza esa línea.
+2
View File
@@ -62,3 +62,5 @@ Qué se aprendió después. Útil cuando un ADR se supersede.
| [0002](0002-apps-analyses-as-dataforge-master.md) | Apps y analyses como sub-repos `dataforge/<name>` con branch master | accepted |
| [0003](0003-orphan-tu-as-separate-function-entry.md) | TU adicional de un parent function como entrada propia | accepted |
| [0004](0004-telemetry-driven-capability-growth.md) | Telemetria de ejecuciones de Claude como motor de crecimiento del registry | accepted |
| [0005](0005-keep-parent-git-lean.md) | Mantener el `.git` del padre ligero: no trackear artefactos hijos, purgar historial, submódulos shallow | accepted |
| [0006](0006-reports-folder.md) | Carpeta `reports/` para reportes de trabajo (entregable de tarea con evidencia) | accepted |
+14 -1
View File
@@ -23,7 +23,10 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [nlp](nlp.md) | 33 | Extraccion NLP: PDFs, OCR, chunking, GLiNER/GLiREL, dedup, agregacion de entities/relations |
| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys |
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
| [web-proxy](web-proxy.md) | 4 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas. Alternativa ligera a ZAP/Burp |
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
| [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO |
| [dav](dav.md) | 9 | Cliente CardDAV/CalDAV (Python, solo stdlib) para Xandikos: parte un .vcf/.ics export de Google en recursos individuales (split puro), extrae/sintetiza UID, sube por HTTP PUT con Basic auth, lista (PROPFIND) y descarga (GET) recursos. Dos pipelines de import (vcf->carddav, ics->caldav). Formaliza la migracion ad-hoc de contactos/calendario |
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
@@ -38,6 +41,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
| [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer |
@@ -45,6 +49,15 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [wireguard](wireguard.md) | 7 | Instalar, configurar, operar y monitorizar mesh WireGuard hub-and-spoke: keygen, hub setup, peer add/revoke, status JSON |
| [matrix-mas](matrix-mas.md) | 5 | Migración Synapse→MAS: habilitar MSC3861, verificar login flows, parche .well-known, registro clientes OAuth2, syn2mas |
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
| [obsidian](obsidian.md) | 16 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve, render tabla Markdown + bloques sentinel gestionados. Sin app GUI |
| [duckdb](duckdb.md) | 5 | Operar bases DuckDB: open (Go), query read-only segura (Python, tipos JSON-safe), CSV->Parquet, dedup por hash, carga OHLCV. Base del patron BD-fuente-de-verdad + Obsidian-vista (app osint_db) |
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
| [market-intel](market-intel.md) | 8 | Inteligencia de mercado para captacion de clientes: scrapers de tendencias de productos/nichos (Amazon, Google Trends, TikTok, AliExpress) + precios de competencia, aterrizados en Postgres (pg_insert_rows/pg_apply_sql) y analizados en Metabase. Dispatcher ingest_market_trends invocado por dag_engine. TikTok/AliExpress por HTTP caen (anti-bot); pendiente browser CDP |
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
## Como anadir grupo
+8 -4
View File
@@ -1,12 +1,14 @@
# Capability: android
Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon Windows), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), logcat streaming. WSL2 -> Windows adb daemon, no requiere Android Studio.
Toolbelt Android **Linux-first** (con fallback WSL2 legacy). Cubre: ADB (`adb_wsl` resuelve el adb nativo del SDK), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), build Gradle nativo (`gradle_*`, `init_kotlin_app`, `run_kotlin_app_tests`), logcat streaming. Usa el SDK nativo en `~/android-sdk` (via `install_android_sdk`); el adb/emulator de Windows solo se usa como fallback cuando se detecta WSL2.
Design system Compose: las apps Kotlin nativas (`init_kotlin_app`) heredan `FnTheme` + `FnTokens` del módulo `kotlin/functions/ui` (`fn.compose:ui`), con la paleta exacta de Mantine v9 dark + indigo (misma que web `@fn_library` y C++ `fn_tokens`).
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]` | Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador. |
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]` | Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot. |
| `android_apk_install_bash_infra` | `android_apk_install([--serial S], apk_path: string, package_name?: string, activity_name?: string) -> void` | Instala APK en device/emulador via adb y opcionalmente lanza la app. Multi-emulator via --serial. |
| `android_app_clear_bash_infra` | `android_app_clear([--serial <S>], package: string) -> void` | Wipe app data + cache via pm clear. App keeps installed but factory-state. Multi-emulator via --serial. |
| `android_app_info_bash_infra` | `android_app_info([--serial <S>], package, [--json]) -> stdout` | Inspect installed app: version, target SDK, activities via dumpsys package. |
@@ -16,8 +18,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
| `android_emu_battery_bash_infra` | `android_emu_battery([--serial <S>], level: int, [--charging <true\|false>]) -> void` | Simulate battery state on emulator (level + charging). Emulator-only. |
| `android_emu_geo_fix_bash_infra` | `android_emu_geo_fix([--serial <S>], longitude: string, latitude: string, [altitude: string]) -> void` | Fake GPS location on Android emulator via emu geo fix. Emulator-only (not physical devices). |
| `android_emu_rotate_bash_infra` | `android_emu_rotate([--serial <S>] [portrait\|landscape\|0\|90\|180\|270])` | Rotate emulator screen. Empty=toggle, or fixed orientation. Locks autorotate. |
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2. |
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro. |
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2. |
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro. |
| `android_emulator_stop_bash_infra` | `android_emulator_stop(serial?: string) -> void` | Para uno o todos los emuladores Android via adb emu kill. Si serial esta vacio, detecta todos los emulator-* activos y los para. Idempotente: exit 0 aunque no haya nada que matar. |
| `android_input_keyevent_bash_infra` | `android_input_keyevent([--serial <S>] key: string)` | Send key event via adb shell input keyevent. Accepts aliases (BACK, HOME, POWER, ENTER, MENU, RECENT_APPS, VOLUME_UP, VOLUME_DOWN), raw numeric codes, or explicit KEYCODE_* names. |
| `android_input_swipe_bash_infra` | `android_input_swipe([--serial <S>], x1: int, y1: int, x2: int, y2: int, [duration_ms: int])` | Send swipe gesture between two points with duration. |
@@ -31,6 +33,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
| `android_shell_bash_infra` | `android_shell([--serial <S>], cmd ...args)` | Execute arbitrary shell command on Android device. Multi-emulator via --serial. |
| `capacitor_build_apk_bash_pipelines` | `capacitor_build_apk(web_app_dir: string, [app_id: string], [app_name: string]) -> void` | Pipeline que convierte una web app en un APK de Android usando Capacitor. Valida el entorno (ANDROID_HOME, Java 17+), construye el bundle web si no existe dist/, inicializa Capacitor si no está configurado, añade la plataforma Android, sincroniza y compila el APK con Gradle. El APK final queda en el directorio raíz de la web app. |
| `deploy_capacitor_to_emulator_bash_pipelines` | `deploy_capacitor_to_emulator(app_dir: string, avd_name?: string, package_name?: string) -> void` | Pipeline end-to-end: build Capacitor APK + arranca AVD + instala + opcionalmente lanza la app. Valida que el AVD existe, construye el APK con capacitor_build_apk, arranca el emulador de forma idempotente, instala el APK y lanza la app si se da package_name. Imprime comando logcat sugerido al final. |
| `fn_theme_kt_ui` | `@Composable fun FnTheme(darkMode: Boolean = true, content: @Composable () -> Unit)` | Provider raiz del design system Compose del registry (@fn_compose). Envuelve MaterialTheme con un ColorScheme derivado de FnColors (Mantine v9 dark + indigo). Dark por defecto, mirror de FnMantineProvider (web) y fn::run_app ThemeMode::FnDark (C++). Toda app del registry envuelve su contenido en FnTheme. |
| `fn_tokens_kt_ui` | `object FnTokens { colors; spacing; radius; typography; shadows }` | Design tokens del design system Compose del registry (@fn_compose). Paleta heredada exacta (mismos hex) de cpp/DESIGN_SYSTEM.md / Mantine v9 dark + indigo: FnColors, FnSpacing (Dp), FnRadius (Dp), FnTypography (sp + weights), FnShadows (Dp). Fuente unica de valores visuales para apps Android del registry. |
| `gradle_assemble_debug_bash_infra` | `gradle_assemble_debug(project_dir: string, module: string) -> string` | Build APK debug de un modulo Android via gradlew assembleDebug. |
| `gradle_clean_bash_infra` | `gradle_clean(project_dir: string) -> int` | Limpia build artifacts de un proyecto Android (gradle clean + rm .gradle + rm build). |
| `gradle_instrumented_test_bash_infra` | `gradle_instrumented_test(project_dir: string, module: string) -> int` | Corre instrumented tests Compose en emulador/device Android conectado. |
+91
View File
@@ -0,0 +1,91 @@
# Capability: claude-direct
Hablar directamente con `https://api.anthropic.com/v1/messages` usando el token OAuth de Claude Code (Claude Max), sin lanzar la CLI `claude` ni necesitar una API key de pago separada. 3 funciones Python en `domain: core`.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `load_claude_oauth_token_py_core` | `def load_claude_oauth_token(credentials_path: str = "", refresh_if_expired: bool = True) -> str` | Lee el access token OAuth desde `~/.claude/.credentials.json`. Verifica expiry (ms-epoch). Intenta refresh best-effort si expirado. |
| `stream_anthropic_messages_py_core` | `def stream_anthropic_messages(messages: list, model: str = "claude-opus-4-8", ...) -> Iterator[dict]` | POST streaming a `/v1/messages`. Yield de eventos normalizados: `text`, `tool_use_start`, `tool_input_delta`, `done`, `error`. Parser SSE puro testeable por separado. |
| `run_claude_tool_loop_py_core` | `def run_claude_tool_loop(messages, tools, dispatch, ...) -> dict` | Bucle agentico tool-use. Llama `stream_anthropic_messages` en loop, despacha tools via `dispatch{name: callable}`, anade `tool_result`, repite hasta `end_turn` o `max_iters`. |
## Ejemplo canonico end-to-end
### Pregunta simple (sin tools)
```python
import sys
sys.path.insert(0, "python/functions/core")
from stream_anthropic_messages import stream_anthropic_messages
text = ""
for event in stream_anthropic_messages(
messages=[{"role": "user", "content": "di solo PONG"}],
model="claude-haiku-4-5-20251001",
max_tokens=32,
):
if event["type"] == "text":
text += event["text"]
print(event["text"], end="", flush=True)
elif event["type"] == "done":
print(f"\n[stop={event['stop_reason']}]")
# Output: PONG
# [stop=end_turn]
```
### Bucle agentico con tool propia
```python
import sys
sys.path.insert(0, "python/functions/core")
from run_claude_tool_loop import run_claude_tool_loop
from datetime import datetime
tools = [
{
"name": "get_time",
"description": "Devuelve la hora actual en formato HH:MM:SS.",
"input_schema": {"type": "object", "properties": {}, "required": []},
}
]
dispatch = {
"get_time": lambda _inp: datetime.now().strftime("%H:%M:%S"),
}
result = run_claude_tool_loop(
messages=[{"role": "user", "content": "que hora es exactamente ahora?"}],
tools=tools,
dispatch=dispatch,
model="claude-haiku-4-5-20251001",
on_text=lambda d: print(d, end="", flush=True),
)
print(f"\n[iters={result['iterations']} stop={result['stop_reason']}]")
# Claude llama a get_time() -> "14:32:07"
# Luego responde: "Ahora son las 14:32:07."
```
### Solo leer el token (para uso manual)
```python
import sys
sys.path.insert(0, "python/functions/core")
from load_claude_oauth_token import load_claude_oauth_token
token = load_claude_oauth_token(refresh_if_expired=False)
# Pasar como header: {"authorization": f"Bearer {token}"}
```
## Fronteras
- **NO cubre** el flujo de refresh OAuth (endpoint no documentado publicamente) — el refresh es best-effort y puede fallar silenciosamente.
- **NO es un cliente completo** de la API de Anthropic: solo `/v1/messages` con streaming. Files, embeddings, etc. quedan fuera.
- **NO reemplaza** el uso de API keys oficiales para produccion — este grupo es exclusivamente para uso local del token OAuth de Claude Max.
- **NO gestiona rate limits** — el caller debe manejar errores `{"type": "error"}` con `429` en el mensaje.
## Prerequisitos
- Claude Code instalado y usuario logueado (`~/.claude/.credentials.json` debe existir).
- `httpx` disponible en el venv: `python/.venv/bin/python3 -c "import httpx"`.
- Token fresco (Claude Code normalmente lo renueva en background mientras esta abierto).
+106
View File
@@ -0,0 +1,106 @@
# dav — Cliente CardDAV/CalDAV (Python, solo stdlib)
Grupo de capacidad para operar un servidor **CardDAV/CalDAV** (Xandikos, git-backed,
en el VPS `magnus`) desde Python sin dependencias externas. Cubre el flujo de
**migracion**: partir un export de Google (un `.vcf` con N contactos, un `.ics` con
N eventos) en recursos individuales y subirlos uno a uno por HTTP PUT con Basic auth.
Tambien listar y descargar recursos para verificar o hacer backup.
Formaliza el flujo ad-hoc (heredocs) que migro 820 contactos + 98 eventos a Xandikos
(regla `function_growth_and_self_docs`: una composicion repetida >2 veces se promueve
a funciones/pipelines del registry).
## Restriccion de diseno
**Solo stdlib** (`urllib.request`, `re`, `hashlib`, `base64`, `ssl`). Sin `requests`,
`caldav` ni `vobject`. El header `Authorization: Basic base64(user:pass)` se construye
a mano. `verify_tls=True` por defecto. Coherente con el grupo `osint-passive` (sin deps).
## Funciones
| ID | Firma corta | Que hace | Purity |
|---|---|---|---|
| `split_vcards_py_infra` | `split_vcards(vcf_text) -> list` | Parte un `.vcf` en VCARDs individuales | pure |
| `split_vevents_to_vcalendars_py_infra` | `split_vevents_to_vcalendars(ics_text, prodid?) -> list` | Parte un VCALENDAR con N VEVENT en N VCALENDARs autonomos (replica VTIMEZONE) | pure |
| `extract_or_make_uid_py_infra` | `extract_or_make_uid(text, prefix?) -> str` | Extrae el `UID:` o sintetiza `<prefix><md5[:16]>` determinista | pure |
| `carddav_put_vcard_py_infra` | `carddav_put_vcard(base_url, user, pw, coll, uid, vcard) -> dict` | PUT de un VCARD (`.vcf`, `text/vcard`) | impure |
| `caldav_put_event_py_infra` | `caldav_put_event(base_url, user, pw, coll, uid, vcal) -> dict` | PUT de un VCALENDAR (`.ics`, `text/calendar`) | impure |
| `dav_list_resources_py_infra` | `dav_list_resources(base_url, user, pw, coll) -> dict` | PROPFIND Depth:1 -> lista de `{href, etag}` | impure |
| `dav_get_resource_py_infra` | `dav_get_resource(base_url, user, pw, href) -> dict` | GET de un recurso -> texto VCARD/VCALENDAR | impure |
| `dav_make_calendar_py_infra` | `dav_make_calendar(base_url, user, pw, calendar_home, slug, name?, color?, desc?) -> dict` | MKCALENDAR + PROPPATCH: crea una coleccion de calendario (agenda) nueva | impure |
| `dav_make_addressbook_py_infra` | `dav_make_addressbook(base_url, user, pw, contacts_home, slug, name?, desc?) -> dict` | Extended MKCOL: crea una coleccion CardDAV (libreta/agenda de contactos) nueva | impure |
| `dav_list_addressbooks_py_infra` | `dav_list_addressbooks(base_url, user, pw, contacts_home) -> dict` | PROPFIND Depth:1: lista las libretas CardDAV del contacts-home con nombre y descripcion | impure |
| `build_vcard_py_core` | `build_vcard(contact: dict) -> str` | Serializa un contacto a VCARD 3.0 MULTI-VALOR (N TEL/EMAIL/ADR + X-OSINT-*); pura | pure |
| `expand_rrule_py_infra` | `expand_rrule(dtstart_ical, rrule, range_start, range_end, all_day?) -> list` | Expande una RRULE iCalendar a las fechas de cada ocurrencia dentro de un rango | pure |
| `import_vcf_to_carddav_py_pipelines` | `import_vcf_to_carddav(vcf_path, base_url, user, pw, coll) -> dict` | Pipeline: .vcf -> split -> uid -> PUT por tarjeta | impure |
| `import_ics_to_caldav_py_pipelines` | `import_ics_to_caldav(ics_path, base_url, user, pw, coll) -> dict` | Pipeline: .ics -> split -> uid -> PUT por evento | impure |
## Sistema real (para los ejemplos)
- Servidor: **Xandikos** en `https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com`, Basic auth, usuario `enmanuel`.
- Password: `pass dav/xandikos-enmanuel` (primera linea). Resolver con `pass_get_secret_py_infra`, NUNCA hardcodear.
- Principal: `/enmanuel/`. Colecciones:
- CardDAV: `/enmanuel/contacts/addressbook/`
- CalDAV: `/enmanuel/calendars/calendar/`
## Ejemplo canonico end-to-end
Importar un `.vcf` exportado de Google a Xandikos, leyendo la password de `pass`:
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from pipelines.import_vcf_to_carddav import import_vcf_to_carddav
BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
summary = import_vcf_to_carddav(
vcf_path="/home/enmanuel/Descargas/contacts.vcf",
base_url=BASE,
username="enmanuel",
password=pw,
collection_path="/enmanuel/contacts/addressbook/",
)
print(summary["ok"], summary["fail"], summary["total"]) # 820 0 820
```
Verificar el resultado listando la coleccion:
```python
from infra.dav_list_resources import dav_list_resources
res = dav_list_resources(BASE, "enmanuel", pw, "/enmanuel/contacts/addressbook/")
print(res["status"], len(res["resources"])) # ok 820
```
El calendario es analogo con `import_ics_to_caldav` + `/enmanuel/calendars/calendar/`.
Desde la CLI del registry (resuelve la pass como variable, no la pongas en claro):
```bash
PW=$(pass show dav/xandikos-enmanuel | head -n1)
./fn run import_vcf_to_carddav /home/enmanuel/Descargas/contacts.vcf \
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
enmanuel "$PW" /enmanuel/contacts/addressbook/
```
## Fronteras
- **No descubre el principal ni las colecciones**: hay que conocer los paths
(`/enmanuel/contacts/addressbook/`, etc.). No implementa `current-user-principal`
ni `addressbook-home-set` discovery.
- **No hace sync incremental** real: `dav_list_resources` devuelve etags pero no
hay logica de diff/merge. Re-importar es idempotente por UID (sobrescribe), no
incremental.
- **No parsea campos VCARD/VEVENT**: trata cada componente como texto opaco. Para
transformar contenido (renombrar, deduplicar por nombre) usa otra herramienta.
- **Solo VEVENT** en calendario: VTODO/VJOURNAL se ignoran al partir el `.ics`.
- **Escrituras irreversibles**: los PUT sobrescriben en el servidor. Idempotente
por UID pero no hay confirmacion previa; valida el `.vcf`/`.ics` antes de importar.
## Prerequisitos
- `pass` configurado con la entrada `dav/xandikos-enmanuel`.
- Conectividad TLS al endpoint publico (`verify_tls=True`).
- Python del registry: `python/.venv/bin/python3`.
+57
View File
@@ -0,0 +1,57 @@
# Capability: duckdb
Operar bases de datos DuckDB desde el registry: abrir/crear bases, consultas read-only seguras, conversion CSV -> Parquet, deduplicacion por hash y carga de series temporales. DuckDB es el motor analitico embebido del ecosistema (OLAP local, archivos `.duckdb`, lectura directa de CSV/Parquet/JSON).
Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (project `osint`): la app `osint_db` posee la DuckDB maestra y este grupo aporta las primitivas de acceso.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `duckdb_open_go_infra` | `DuckDBOpen(path string) (*sql.DB, error)` | Abre (o crea) una base DuckDB desde Go. Path vacio o `:memory:` abre en memoria. |
| `duckdb_query_readonly_py_infra` | `duckdb_query_readonly(db_path, sql, params=None, max_rows=10000) -> dict` | Consulta read-only segura: conexion `read_only=True`, params posicionales `?`, filas como `list[dict]` con tipos normalizados a JSON (date/datetime -> isoformat, Decimal -> float, bytes -> base64). Devuelve `{status, columns, rows, row_count, truncated}` sin lanzar. |
| `duckdb_execute_py_infra` | `duckdb_execute(db_path, sql, params=None) -> dict` | Ejecuta UNA sentencia de escritura (INSERT/UPDATE/DELETE/DDL) en conexion read-write, commit, devuelve `{status, rowcount}` sin lanzar. Primitivo de escritura del grupo (complementa a `duckdb_query_readonly`). |
| `duckdb_upsert_py_infra` | `duckdb_upsert(db_path, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ...` actualizando SOLO `update_cols`. Excluir columnas de `update_cols` permite que un re-upsert NO las pise (ownership selectivo: la DB es la verdad). Devuelve `{status, inserted, updated}`. |
| `csv_to_parquet_duckdb_py_core` | `csv_to_parquet_duckdb(csv_path, parquet_path, column_casts=None, overwrite=False) -> bool` | Convierte CSV -> Parquet con `read_csv_auto`. `column_casts` fuerza tipos por columna. No reescribe si el parquet existe y `overwrite=False`. |
| `dedup_duckdb_table_by_hash_py_pipelines` | `dedup_duckdb_table_by_hash(duckdb_path, table, exclude_cols=None) -> dict` | Pipeline: anade columna `row_hash` (md5 de columnas de datos) idempotentemente y borra filas duplicadas conservando la primera insercion. |
| `load_ohlcv_from_duckdb_go_finance` | `LoadOHLCVFromDuckDB(dbPath, query string) ([][]float64, error)` | Carga datos OHLCV ejecutando una query SQL sobre una base DuckDB (consumo desde apps Go de finanzas). |
## Ejemplo canonico
Consulta read-only desde cualquier sesion (la conexion se abre `read_only=True` y se cierra siempre):
```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys
sys.path.insert(0, "python/functions")
from infra import duckdb_query_readonly
res = duckdb_query_readonly(
"projects/osint/apps/osint_db/data/osint.duckdb",
"SELECT contexto, COUNT(*) AS n FROM persons GROUP BY contexto ORDER BY n DESC",
max_rows=50,
)
print(res["status"], res["row_count"])
for row in res["rows"]:
print(row)
PYEOF
```
Conversion CSV -> Parquet en una linea:
```bash
./fn run csv_to_parquet_duckdb datos.csv datos.parquet
```
## Gotchas del grupo
- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service. Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
- **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo.
- `read_only=True` exige que el archivo exista — no crea bases nuevas.
## Fronteras
- NO cubre SQLite (`sqlite_open_go_infra` y el grupo de operations.db van aparte).
- NO cubre el render de resultados a Markdown/notas — eso es `render_markdown_table_py_core` + `upsert_sentinel_block_py_core` (grupo `obsidian`).
- El analisis exploratorio pesado (notebooks) vive en `analysis/` con sus propios venvs.
+97
View File
@@ -0,0 +1,97 @@
---
group: e2e-messaging
description: "Criptografía extremo a extremo para bus de mensajería: identidades duales Ed25519/X25519, distribución de claves de sala con sealed box anónimo, cifrado simétrico AEAD por mensaje, y firma/verificación de mensajes."
functions:
- generate_identity_go_cybersecurity
- seal_aead_go_cybersecurity
- open_aead_go_cybersecurity
- seal_key_box_go_cybersecurity
- open_key_box_go_cybersecurity
- sign_ed25519_go_cybersecurity
- verify_ed25519_go_cybersecurity
---
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| `generate_identity_go_cybersecurity` | `GenerateIdentity() (Identity, error)` | Genera par Ed25519 (firma) + par X25519 (kex) para un participante |
| `seal_aead_go_cybersecurity` | `SealAEAD(key, plaintext, aad []byte) (nonce, ct []byte, err error)` | Cifra mensaje con ChaCha20-Poly1305, nonce aleatorio por llamada |
| `open_aead_go_cybersecurity` | `OpenAEAD(key, nonce, ct, aad []byte) ([]byte, error)` | Descifra y autentica; error explícito si el tag falla |
| `seal_key_box_go_cybersecurity` | `SealKeyBox(recipientKexPub, secret []byte) ([]byte, error)` | Cifra room key para un destinatario con su X25519 pubkey (sealed box anónimo) |
| `open_key_box_go_cybersecurity` | `OpenKeyBox(kexPub, kexPriv, sealedMsg []byte) ([]byte, error)` | Abre sealed box con el par X25519 propio para recuperar la room key |
| `sign_ed25519_go_cybersecurity` | `SignEd25519(priv, msg []byte) []byte` | Firma determinista Ed25519 (pura, sin I/O) |
| `verify_ed25519_go_cybersecurity` | `VerifyEd25519(pub, msg, sig []byte) bool` | Verifica firma Ed25519 (pura, sin I/O) |
## Ejemplo canónico end-to-end
```go
package main
import (
"fmt"
"log"
cs "fn-registry/functions/cybersecurity"
)
func main() {
// 1. Cada participante genera su identidad una sola vez
server, err := cs.GenerateIdentity()
if err != nil { log.Fatal(err) }
user, err := cs.GenerateIdentity()
if err != nil { log.Fatal(err) }
// 2. Servidor genera room key y la distribuye al usuario cifrada
roomKey := make([]byte, 32)
// ... llenar roomKey con crypto/rand en producción ...
sealed, err := cs.SealKeyBox(user.KexPub, roomKey)
if err != nil { log.Fatal(err) }
// 3. Usuario recupera la room key
gotKey, err := cs.OpenKeyBox(user.KexPub, user.KexPriv, sealed)
if err != nil { log.Fatal(err) }
// 4. Usuario cifra un mensaje con la room key
aad := []byte("room:sala-general:seq:1")
nonce, ct, err := cs.SealAEAD(gotKey, []byte("hola sala"), aad)
if err != nil { log.Fatal(err) }
// 5. Usuario firma el ciphertext para autenticar autoría
sig := cs.SignEd25519(user.SignPriv, ct)
// 6. Receptor verifica firma y descifra
if !cs.VerifyEd25519(user.SignPub, ct, sig) {
log.Fatal("firma inválida")
}
plain, err := cs.OpenAEAD(gotKey, nonce, ct, aad)
if err != nil { log.Fatal(err) }
fmt.Printf("recibido: %s\n", plain)
_ = server // server.SignPub publicado en directorio de participantes
}
```
## Fronteras
Este grupo cubre las primitivas criptográficas del bus, no el protocolo completo:
- **No cubre**: transporte (WebSocket, gRPC), gestión de sesiones, ratchet de claves (doble ratchet), persistencia de identidades, revocación de claves.
- **No cubre**: cifrado de archivos adjuntos (usar SealAEAD directamente con una key derivada).
- **No reemplaza**: libsodium ni libolm para implementaciones de producción de Signal/Matrix — estas funciones son el sustrato criptográfico, no el protocolo completo.
## Prerequisitos
- `golang.org/x/crypto` ya en `go.mod` (presente en fn-registry).
- `crypto/ed25519` de stdlib (Go 1.13+).
- Identidades persistidas de forma segura (keyring, HSM, archivo cifrado): este grupo no gestiona almacenamiento.
## Patrón de uso recomendado
```
GenerateIdentity() → persiste Identity por participante
SealKeyBox(kexPub, roomKey) → distribuye room key al unirse a sala
OpenKeyBox(kexPub, kexPriv) → recupera room key
SealAEAD(roomKey, msg, aad) → cifra cada mensaje
SignEd25519(signPriv, ct) → autentica autoría sobre ciphertext
VerifyEd25519(signPub, ct) → verifica antes de descifrar
OpenAEAD(roomKey, nonce, ct)→ descifra mensaje verificado
```
+117
View File
@@ -0,0 +1,117 @@
# Flow Replay — Guardar un flujo web como función reproducible
Tag: `flow-replay`. Grupo de funciones para convertir un flujo de navegador que se hizo
una vez a mano (login en un panel, reiniciar un servidor, rellenar un formulario) en una
**función del registry reproducible sin intervención**. Materializa la doctrina del issue
0087: el registry crece promoviendo secuencias repetidas a operaciones de un solo paso.
Filtro MCP: `mcp__registry__fn_search query="" tag="flow-replay"`.
Complementa al grupo [`web-proxy`](web-proxy.md): `web-proxy` **graba** el tráfico,
`flow-replay` lo **destila y reproduce**.
## El patrón: grabar → destilar → reproducir
Tres fases, con una jerarquía de reproducción de más barato a más caro:
```
Fase 0 — GRABAR (una vez, siempre con browser + proxy)
web_proxy ON → haces la acción a mano en el navegador → exportas el tramo a HAR
(funciones del grupo web-proxy: start_mitm_capture, launch_chromium_proxy, query_mitm_flows --har)
Fase 1 — DESTILAR (del HAR a una secuencia de requests)
har_filter_flows → descarta estáticos/analytics, deja los flujos que importan
har_extract_calls → normaliza cada flujo a una "call spec" reproducible (método, url,
headers, cookies, body), aislando los datos de auth
Fase 2 — REPRODUCIR, en orden de preferencia:
Nivel 1 HTTP puro http_replay_sequence — rápido, headless, scriptable. PREFERIDO.
Nivel 2 headless chromium (fallback) — cuando hay token dinámico firmado en cliente,
challenge JS o WAF con fingerprint de navegador. Reutiliza
cdp_extract_recipe + cdp_save_storage_state (ver Fronteras).
Nivel 3 chromium visible + acciones humanizadas — último recurso si headless es detectado
(cdp_click_xy_human, cdp_move_mouse_human del dominio browser).
```
La función-acción concreta que guardas en el registry (`reboot_<panel>_server`,
`login_<panel>`, etc.) envuelve el nivel que funcione: idealmente una llamada a
`http_replay_sequence` con su secuencia + parámetros, y los secretos resueltos desde
`pass`/vault.
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| [har_filter_flows_py_cybersecurity](../../python/functions/cybersecurity/har_filter_flows.md) | `har_filter_flows(har, *, hosts, methods, drop_static, drop_analytics) -> list[dict]` | Filtra un HAR: descarta recursos estáticos y hosts de telemetría, deja los flujos candidatos a "acción". Pura. |
| [har_extract_calls_py_cybersecurity](../../python/functions/cybersecurity/har_extract_calls.md) | `har_extract_calls(entries, *, drop_headers) -> list[dict]` | Convierte entries HAR en "call specs" normalizadas (método/url/headers/cookies/body/body_type), aislando cookies de auth y descartando headers hop-by-hop. Pura. |
| [http_replay_sequence_py_infra](../../python/functions/infra/http_replay_sequence.md) | `http_replay_sequence(calls, *, params, extract, timeout_s, verify_tls, allow_redirects, base_headers) -> dict` | Motor de replay HTTP: ejecuta la secuencia compartiendo cookie jar, substituye `{{param}}` y extrae valores de una respuesta para inyectarlos en pasos siguientes (flujo CSRF-like). Impura. |
## Ejemplo canónico end-to-end
Destilar un HAR capturado y reproducir el flujo sin navegador. Las tres funciones se
encadenan; la extracción del paso 1 (un token) se inyecta en el paso 2:
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity.har_filter_flows import har_filter_flows
from cybersecurity.har_extract_calls import har_extract_calls
from infra.http_replay_sequence import http_replay_sequence
# 1. HAR exportado por: query_mitm_flows ~/captures/traffic-*.mitm --har ~/sesion.har
import json
har = json.load(open(os.path.expanduser("~/sesion.har")))
# 2. Destilar: del ruido a la secuencia mínima
flows = har_filter_flows(har, hosts=["panel.midominio.com"]) # solo el host del panel
calls = har_extract_calls(flows) # call specs reproducibles
# 3. Reproducir (Nivel 1, HTTP puro). El token del GET inicial se inyecta en el POST.
res = http_replay_sequence(
calls,
params={"server_id": "vps-42"}, # parametrizado por el caller
extract=[{"from": 0, "type": "json", "expr": "csrf", "as": "csrf"}],
verify_tls=True,
)
print(res["status"], [s["status_code"] for s in res["steps"]])
```
Una vez validado, el flujo se promueve a una función-acción nombrada del registry
(p. ej. `reboot_vps_server_<panel>`) que internamente llama a `http_replay_sequence`
con su secuencia fija, recibe los parámetros del caller y resuelve los secretos desde
`pass`. Esa función-acción es lo que el agente invoca en un solo paso a partir de entonces.
## Fronteras
- **No graba**: la captura es del grupo [`web-proxy`](web-proxy.md). Este grupo empieza
con un HAR ya existente.
- **No auto-parametriza** (todavía). `har_extract_calls` normaliza pero NO detecta solo
qué valor es un token dinámico ni dónde se reinyecta. La parametrización (`{{param}}`)
y las reglas de `extract` las decide el humano/agente leyendo el HAR. La detección
automática de tokens/CSRF sería una función nueva del grupo, no una ampliación.
- **No incluye el runner de Nivel 2/3** (browser fallback). Está especificado en el
patrón pero no implementado: cuando un flujo real falle en HTTP puro, se construye un
"action recipe" reutilizando casi entero `cdp_extract_recipe_py_pipelines` (mismo
formato YAML, steps de acción en vez de extracción) + `cdp_save_storage_state_go_browser`
para saltarse el login. No se construye por adelantado (KISS / registry-first).
- **No gestiona secretos**: los secretos viajan como `{{param}}` desde `pass`/vault. El
grupo nunca los hardcodea ni los persiste.
## Gotchas (seguridad — leer antes de usar)
- **El HAR es sensible**: contiene cookies y tokens en crudo. Trátalo como un secreto —
gitignored, no subir a Gitea, no indexar, borrar tras destilar. El output de
`har_extract_calls` también lleva esos valores hasta que los sustituyes por `{{param}}`.
- **Secretos a `pass`/vault**, nunca en el código de la función-acción.
- **Replay con efectos = peligroso**: reproducir un POST que reinicia, borra o paga es
destructivo. La función-acción debe pedir confirmación o exponer un flag explícito
(`--yes`/`confirm=True`) antes de disparar. Nunca replay ciego de una acción irreversible.
- **HTTP puro no siempre reproduce**: token firmado en cliente, challenge JS, o WAF que
exige fingerprint de navegador → cae a Nivel 2 (headless) o 3 (visible humanizado).
- `http_replay_sequence` sigue redirects por defecto y `verify_tls=True`. La extracción
JSON es dot-path simple (`a.b.0.c`), no JSONPath completo.
## Prerequisitos
- Fase 0 (grabar): grupo `web-proxy` operativo (mitmproxy + chromium). Ver su página.
- Fase 1-2: `requests` en `python/.venv` (ya presente). Sin dependencias nuevas.
+83
View File
@@ -0,0 +1,83 @@
# Capability group: `hoppscotch`
Operar una instancia **self-hosted de Hoppscotch** (consola de APIs, alternativa open-source a
Postman) desde el registry, vía su **API GraphQL**. El agente crea/edita requests, colecciones y
environments por la API; el humano los ve **en vivo** en su GUI (subscriptions = hot-reload real).
Las requests viven en la base de datos del self-host (Postgres), compartida entre el agente y la GUI.
Este es el **flujo canónico**. El antiguo modo "archivo `.json` local" (funciones
`parse_*` / `run_*` / `add_hoppscotch_request`) **fue eliminado**: escribía un `.json` en disco que
NO subía al workspace, así que el humano no lo veía en la GUI. No lo reintroduzcas.
## Stack self-host
Vive en `projects/web_scraping/hoppscotch/selfhost/` (docker compose: AIO + Postgres + mailpit).
| Servicio | URL | Para qué |
|---|---|---|
| App (cliente) | `http://localhost:3009` | la GUI donde el humano usa las colecciones (instalable como PWA) |
| Admin dashboard | `http://localhost:3100` | gestión (usuarios, config) |
| Backend GraphQL | `http://localhost:3170/graphql` | la API que usan las funciones |
| Mailpit | `http://localhost:8025` | captura el magic link del login (SMTP de pruebas, sin correo real) |
Levantar: `cd selfhost && docker compose up -d`. Team de trabajo: **"registry"**. Cuenta: `admin@example.com`.
## Funciones
| ID | Firma corta | Qué hace |
|---|---|---|
| `hoppscotch_login_py_infra` | `(email, *, backend_url, mailpit_url) -> {access_token,...}` | login por magic link headless (lee el link de mailpit) → JWT |
| `hoppscotch_create_request_py_infra` | `(collection_id, method, url, *, title, headers, body, body_type, team_id, access_token) -> dict` | crea una request en una colección de la team |
| `hoppscotch_update_request_py_infra` | `(request_id, method, url, *, title, headers, body, body_type, access_token) -> dict` | actualiza una request |
| `hoppscotch_delete_request_py_infra` | `(request_id, *, access_token) -> dict` | borra una request |
| `hoppscotch_list_requests_py_infra` | `(collection_id, *, access_token) -> {requests:[...]}` | lista las requests de una colección |
| `hoppscotch_set_environment_py_infra` | `(team_id, name, variables, *, access_token) -> dict` | crea/actualiza (idempotente) el environment de la team; resuelve secretos `pass:` |
| `build_hoppscotch_collection_py_infra` | `(calls, *, name, request_names) -> dict` | **helper interno** de create/update: serializa call specs al formato HoppRESTRequest. NO para escribir `.json` a mano |
| `pass_get_secret_py_infra` | `(path, *, line) -> {value}` | lee un secreto de `pass` (lo consume `set_environment` para no hardcodear keys) |
`access_token` se pasa como **cookie**, no header `Authorization`. Caduca a 24h → re-login con `hoppscotch_login`.
## Ejemplo canónico (end-to-end)
```python
import sys, os
sys.path.insert(0, os.path.join(os.path.expanduser("~/fn_registry"), "python", "functions"))
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_create_request import hoppscotch_create_request
from infra.hoppscotch_set_environment import hoppscotch_set_environment
TEAM = "cmq8kn0v500030xls1nvminjy" # team "registry"
COLL = "cmq8knppc00040xlskt4ist27" # colección registry_api (de hoppscotch_list/DB)
tok = hoppscotch_login("admin@example.com")["access_token"]
# 1. Variables del workspace (secreto resuelto desde pass, no hardcodeado)
hoppscotch_set_environment(TEAM, "registry", [
{"key": "baseURL", "value": "https://registry.organic-machine.com", "secret": False},
{"key": "api_key", "value": "pass:apis/registry", "secret": True}, # pass: -> pass_get_secret
], access_token=tok)
# 2. Crear una request → aparece EN VIVO en la GUI del humano (subscriptions)
hoppscotch_create_request(
COLL, "GET", "<<baseURL>>/api/status",
title="status", headers={"Accept": "application/json"},
team_id=TEAM, access_token=tok,
)
```
## Fronteras (qué NO cubre)
- **No es modo archivo**: no escribe colecciones `.json` locales como fuente. Las requests viven en el
Postgres del self-host. (Los `.json` en `collections/` son solo respaldo/semilla importable.)
- **No automatiza la GUI**: opera por la API; la GUI la mira el humano.
- **No gestiona usuarios/teams del dashboard**: eso es el admin dashboard (`:3100`).
- **No ejecuta los scripts pre/post-request JS** de Hoppscotch.
## Gotchas
- `access_token` como **cookie** (`cookies={"access_token": tok}`), no `Authorization`. 24h de vida.
- `createRequestInCollection` de esta instancia **exige `team_id`** en el input (no solo el collectionID).
- Variables `<<var>>` se resuelven con el environment de la team (subscriptions las propagan a la GUI).
- Secretos: usa `value="pass:<ruta>"` en `set_environment` → se resuelve de `pass`, nunca se hardcodea
ni se logea en crudo.
- El secreto viaja en claro al backend local por GraphQL — es local (`127.0.0.1`), aceptable.
+54
View File
@@ -0,0 +1,54 @@
# market-intel
Inteligencia de mercado para captación de clientes: scrapers de señales de demanda y
tendencias de productos/nichos desde varias fuentes públicas, más vigilancia de precios de
la competencia, aterrizados en Postgres y analizados con Metabase. Scheduling con
`dag_engine`. Origen: proyecto `captacion_clientes`.
## Funciones
| ID | Firma corta | Qué hace |
|---|---|---|
| `scrape_amazon_bestsellers_py_datascience` | `(marketplace, categories, list_type, max_items)` | Amazon Best Sellers + Movers & Shakers (ranking real de demanda). HTTP, funciona. |
| `scrape_google_trends_py_datascience` | `(keywords, geo, timeframe, include_related)` | Interés de búsqueda (0-100) + rising/top via pytrends. Backoff ante 429. |
| `scrape_tiktok_creative_py_datascience` | `(country, kind, limit, period)` | TikTok Creative Center (hashtags/songs/creators). **Bloqueado por anti-bot vía HTTP**; pendiente browser CDP. |
| `scrape_aliexpress_trending_py_datascience` | `(query, category, limit, ship_to)` | Productos populares AliExpress (orders/rating). **Bloqueado por captcha vía HTTP**; pendiente browser CDP. |
| `scrape_competitor_prices_py_datascience` | `(targets) -> list[dict]` | Precio actual de una lista de URLs de competidores (cascada: selector → JSON-LD → meta → heurística). |
| `pg_insert_rows_py_infra` | `(dsn, table, rows, add_snapshot_date=True)` | Insert append-only por lote en Postgres (execute_values parametrizado, añade snapshot_date). |
| `pg_apply_sql_py_infra` | `(dsn, sql_path) -> int` | Aplica un `.sql` de migración a Postgres (idempotente con IF NOT EXISTS). |
| `ingest_market_trends_py_pipelines` | `(source)` | Dispatcher: scrapea una fuente y la aterriza en su tabla. Lo invoca `dag_engine`. |
## Ejemplo canónico (end-to-end)
```bash
# 1. (una vez) Stack Metabase + Postgres en Docker
fn run init_metabase_go_infra --project captacion --metabase-port 3030 --pg-port 5433 \
--pg-user captacion --pg-password "$(pass show captacion/postgres | head -1)"
docker exec captacion-postgres psql -U captacion -d metabase -c "CREATE DATABASE trends OWNER captacion"
# 2. (una vez) Aplicar el schema
python3 -c "import sys; sys.path.insert(0,'python/functions'); from infra import pg_apply_sql; \
pg_apply_sql('postgresql://captacion:PW@localhost:5433/trends', 'projects/captacion_clientes/db/migrations/001_schema.sql')"
# 3. Ingesta una fuente (manual o vía dag_engine)
fn run ingest_market_trends_py_pipelines amazon
fn run ingest_market_trends_py_pipelines google_trends
# 4. dag_engine lo hace solo: dags market-intel-daily (06:30) y competitor-prices-hourly
```
## Fronteras
- NO hace explotación ni bypass agresivo de anti-bot: TikTok/AliExpress por HTTP-directo
caen desde datacenter; la vía robusta es el browser MCP/CDP (grupo `navegator`/`web-proxy`,
doctrina `flow_replay.md`), aún no implementada para estas dos fuentes.
- NO es un grupo de visualización: el análisis vive en Metabase (grupo `metabase`).
- NO gestiona el scheduling: eso es `dag_engine` (grupo `scheduler`).
- El DSN de Postgres y credenciales NO se hardcodean: van en `pass`/`.env` del proyecto.
## Notas
- Las tablas de `trends` son append-only particionadas por `snapshot_date` — pensadas para
series temporales en Metabase (qué tendencia sube/baja). No correr en bucle apretado.
- `competitor_prices` se nutre de la tabla `competitor_targets` (el usuario inserta los
objetivos a vigilar: competidor + product_key + URL).

Some files were not shown because too many files have changed in this diff Show More