6 Commits

Author SHA1 Message Date
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
42 changed files with 4113 additions and 47 deletions
+2 -1
View File
@@ -21,7 +21,7 @@ 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). **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`.
**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`.
@@ -193,6 +193,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.
+1
View File
@@ -39,3 +39,4 @@ 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. |
+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
@@ -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,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,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"
+1
View File
@@ -46,6 +46,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [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 |
## Como anadir grupo
+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).
+44
View File
@@ -0,0 +1,44 @@
---
title: <objetivo de la sesion en una frase>
artefacto: <app|analysis|project|registry|none> · <ruta relativa si aplica>
created: DD/MM/AAAA HH:mm
updated: DD/MM/AAAA HH:mm
status: in_progress
related_issues: []
related_flows: []
---
## Objetivo
<que se quiere conseguir, condiciones de done. Una o dos lineas. Esto fija el alcance.>
## Pendiente
- [ ] 1. <tarea> — <nota / dependencia>
- [ ] 2. <tarea>
- [ ] 3. <tarea>
## En curso
- [~] <tarea> — <progreso actual, donde se quedo, siguiente paso concreto>
## Hecho
- [x] <tarea>
- resultado: <que produjo / como se verifico>
- enlace: <url, path de archivo, id de funcion, hash de commit>
## Enlaces
- <descripcion> — <url o path>
## Issues / flows relacionados
- issue <NNNN> — <titulo> — <estado>
- flow <slug> — <titulo> — <estado>
## Notas
- <decision tomada y por que>
- <bloqueo / pregunta abierta>
- <contexto que no cabe en el codigo>
+116
View File
@@ -0,0 +1,116 @@
package browser
import (
"fmt"
"math/rand"
"strings"
"time"
)
// CdpClickHuman hace click en el elemento identificado por selector CSS con
// movimiento humano: obtiene el bbox, calcula un punto destino ligeramente
// desplazado del centro, mueve el ratón por una trayectoria de Bézier cúbica
// y luego despacha mousePressed/mouseReleased con una micro-pausa entre ellos.
//
// opts controla la trayectoria del movimiento previo al click.
// Para configurar el origen del movimiento usa opts.FromX / opts.FromY.
func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click human: conexion nula")
}
// Obtener bounding box del selector
js := fmt.Sprintf(`(function() {
var el = document.querySelector(%q);
if (!el) return null;
var r = el.getBoundingClientRect();
return JSON.stringify({x: r.left, y: r.top, w: r.width, h: r.height});
})()`, selector)
bboxStr, err := CdpEvaluate(c, js)
if err != nil {
return fmt.Errorf("cdp click human: obtener bbox de %q: %w", selector, err)
}
if bboxStr == "" || bboxStr == "null" {
return fmt.Errorf("cdp click human: elemento %q no encontrado en el DOM", selector)
}
bboxStr = strings.Trim(bboxStr, `"`)
bx, by, bw, bh, err := parseBbox(bboxStr)
if err != nil {
return fmt.Errorf("cdp click human: parsear bbox %q: %w", bboxStr, err)
}
// Scroll al elemento para que sea visible
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
if _, err := CdpEvaluate(c, scrollJS); err != nil {
_ = err // no fatal
}
// Punto destino: centro + pequeño offset aleatorio (±15% del tamaño)
offX := (rand.Float64()*2 - 1) * bw * 0.15
offY := (rand.Float64()*2 - 1) * bh * 0.15
toX := bx + bw/2 + offX
toY := by + bh/2 + offY
// Mover el ratón con trayectoria humana
if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil {
return fmt.Errorf("cdp click human: mover raton: %w", err)
}
// mousePressed
clickParams := map[string]any{
"type": "mousePressed",
"x": toX,
"y": toY,
"button": "left",
"clickCount": 1,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click human: mousePressed: %w", err)
}
// Micro-pausa humana entre press y release (3090 ms)
pauseMs := 30 + rand.Intn(61)
time.Sleep(time.Duration(pauseMs) * time.Millisecond)
// mouseReleased
clickParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click human: mouseReleased: %w", err)
}
return nil
}
// parseBbox extrae left, top, width, height de un JSON como {"x":10,"y":20,"w":100,"h":40}.
func parseBbox(s string) (left, top, width, height float64, err error) {
// Reutiliza el mismo parser manual que parseCoords para evitar encoding/json
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "{")
s = strings.TrimSuffix(s, "}")
for part := range strings.SplitSeq(s, ",") {
kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
if len(kv) != 2 {
continue
}
k := strings.Trim(strings.TrimSpace(kv[0]), `"`)
var v float64
if _, e := fmt.Sscanf(strings.TrimSpace(kv[1]), "%f", &v); e != nil {
err = fmt.Errorf("parsear valor %q: %w", kv[1], e)
return
}
switch k {
case "x":
left = v
case "y":
top = v
case "w":
width = v
case "h":
height = v
}
}
return
}
+72
View File
@@ -0,0 +1,72 @@
---
name: cdp_click_human
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error"
description: "Hace click en el elemento identificado por selector CSS con comportamiento humano: obtiene el bounding box, calcula un destino ligeramente desplazado del centro, mueve el ratón con CdpMoveMouseHuman (curva de Bézier cúbica + easing + jitter) y despacha mousePressed/mouseReleased con micro-pausa de 30-90 ms entre ellos."
tags: [cdp, chrome, browser, mouse, human, click, navegator]
uses_functions:
- cdp_evaluate_go_browser
- cdp_move_mouse_human_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- fmt
- math/rand
- strings
- time
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_click_human.go"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: selector
desc: "Selector CSS del elemento a clickear (ej. '#submit-btn', '.nav-item:first-child')."
- name: opts
desc: "MouseHumanOpts que controla la trayectoria del movimiento previo. Usa opts.FromX/FromY para definir el origen del movimiento (default 0,0)."
output: "error si la conexión es nula, el elemento no existe en el DOM, o falla algún evento CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
defer CdpClose(conn, 0)
CdpNavigate(conn, "https://example.com/login")
CdpWaitElement(conn, "#username", 5*time.Second)
// Click humano en el campo usuario desde la esquina superior izquierda
err := CdpClickHuman(conn, "#username", MouseHumanOpts{
FromX: 50,
FromY: 50,
})
// Click en el botón submit viniendo desde donde está el campo usuario
err = CdpClickHuman(conn, "#submit-btn", MouseHumanOpts{
FromX: 350, // aproximadamente donde quedó el cursor anterior
FromY: 280,
DurationMs: 500,
Steps: 30,
})
```
## Cuando usarla
Sustituye a `CdpClick` cuando el sitio detecta clicks instantáneos sin movimiento previo o cuando el punto de click exactamente en el centro del elemento activa checks anti-bot. Usar en formularios de login, CAPTCHAs de comportamiento, botones con honeypot invisible en el centro exacto.
## Gotchas
- El destino final se desplaza ±15% del tamaño del elemento respecto al centro para evitar siempre clickear en el pixel exacto. En elementos muy pequeños (<5px) el offset puede salir fuera del elemento — usar `CdpClick` en esos casos.
- Hace `scrollIntoView` antes del movimiento. Si el elemento está en el fold inferior, el scroll ocurre y las coordenadas de la curva Bézier ya reflejan la posición post-scroll. Sin embargo, si el scroll produce reflow del DOM (lazy-load), puede que el selector cambie de posición durante el movimiento.
- La micro-pausa de 30-90 ms entre mousePressed y mouseReleased está codificada en el rango típico humano. No hay opción para ajustarla — si necesitas control total, llama `CdpMoveMouseHuman` + `Input.dispatchMouseEvent` manualmente.
- No garantiza indetectabilidad total. Ver `## Gotchas` de `cdp_move_mouse_human_go_browser`.
- Requiere que el elemento sea visible (no `display:none` ni `visibility:hidden`). `getBoundingClientRect` retorna todos ceros para elementos ocultos, produciendo click en (0,0).
- `opts.FromX` y `opts.FromY` deben ser la posición actual real del cursor para que la trayectoria sea convincente. Si no conoces la posición actual, pasa el centro aproximado de la última acción.
+15 -9
View File
@@ -3,9 +3,12 @@ package browser
import (
"fmt"
"os"
"syscall"
)
// CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome.
// En Linux nativo mata el grupo de proceso completo (chromium lanza zygote, gpu,
// renderers como hijos del mismo grupo cuando ChromeLaunch seteo Setpgid: true).
// Siempre intenta cerrar la conexion aunque el kill falle, y viceversa.
// Retorna el primer error encontrado.
func CdpClose(c *CDPConn, pid int) error {
@@ -19,16 +22,19 @@ func CdpClose(c *CDPConn, pid int) error {
}
if pid > 0 {
proc, err := os.FindProcess(pid)
if err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, err)
}
} else {
if err := proc.Kill(); err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, err)
// Intentar matar el grupo de proceso completo (pid == pgid cuando Setpgid=true).
// syscall.Kill con pid negativo envia la señal a todos los procesos del grupo.
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
// Fallback: matar solo el proceso raiz si el grupo falla
// (ej: proceso ya terminado, o chrome.exe en WSL sin Setpgid).
if proc, e := os.FindProcess(pid); e == nil {
if killErr := proc.Kill(); killErr != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, killErr)
}
}
} else if firstErr == nil {
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, e)
}
}
}
+25 -10
View File
@@ -3,23 +3,23 @@ name: cdp_close
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "func CdpClose(c *CDPConn, pid int) error"
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
tags: [chrome, cdp, browser, automation, cleanup, devtools]
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os]
imports: [fmt, os, syscall]
params:
- name: c
desc: "conexión CDP (puede ser nil)"
desc: "conexión CDP (puede ser nil para solo matar el proceso)"
- name: pid
desc: "PID del proceso Chrome (0 para no matar)"
output: "error si falla la desconexión o el cierre del proceso"
desc: "PID del proceso Chrome (0 para no matar; en Linux nativo este PID es tambien el PGID cuando ChromeLaunch uso Setpgid)"
output: "error si falla la desconexion o el cierre del proceso; nil si todo OK"
tested: false
tests: []
test_file_path: ""
@@ -32,13 +32,28 @@ file_path: "functions/browser/cdp_close.go"
pid, _ := ChromeLaunch(ChromeLaunchOpts{Port: 9222, Headless: true})
conn, _ := CdpConnect(9222)
defer CdpClose(conn, pid) // cierra WebSocket y mata Chrome
defer CdpClose(conn, pid) // cierra WebSocket y mata grupo Chrome completo
// O por separado:
defer CdpClose(conn, 0) // solo cierra WebSocket
defer CdpClose(nil, pid) // solo mata Chrome
defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux)
```
## Cuando usarla
Usar siempre en `defer` después de `ChromeLaunch` para garantizar cleanup del proceso Chrome y del WebSocket CDP. En Linux nativo mata el árbol completo de procesos (zygote, gpu, renderers) evitando procesos zombie.
## Gotchas
- **Kill por grupo (Linux nativo)**: usa `syscall.Kill(-pid, SIGKILL)` que envía la señal a todos los procesos del grupo. Funciona porque `ChromeLaunch` setea `Setpgid: true` en Linux, haciendo que `pid == pgid`. En WSL+chrome.exe el Setpgid no se aplica, por lo que el fallback a `os.FindProcess(pid).Kill()` maneja ese caso.
- **Fallback automático**: si el kill de grupo falla (proceso ya terminado, PID no encontrado, o es WSL+exe), intenta matar solo el proceso raiz. En ambos casos el error no es fatal si el proceso ya no existe.
- **Doble cierre seguro**: marca `c.closed = true` para evitar doble cierre del WebSocket. El segundo `CdpClose` con la misma conexión es un no-op en el lado WebSocket.
- **Primer error**: si tanto el cierre WebSocket como el kill fallan, retorna el error del WebSocket (el primero en ejecutarse). El kill siempre se intenta aunque el WebSocket falle.
## Notas
Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo. Marca `c.closed = true` para evitar doble cierre.
Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo.
## Capability growth log
- v1.1.0 (2026-06-05) — Linux-native kill: usa syscall.Kill(-pid, SIGKILL) para matar grupo completo (zygote, gpu, renderers), con fallback a os.FindProcess para WSL+exe o proceso ya terminado
+161
View File
@@ -0,0 +1,161 @@
package browser
import (
"fmt"
"math"
"math/rand"
"time"
)
// MouseHumanOpts configura el movimiento humano del ratón.
type MouseHumanOpts struct {
// Steps es el número de puntos intermedios de la curva (default 25).
Steps int
// DurationMs es la duración total aproximada del movimiento en milisegundos.
// Si es 0, se elige aleatoriamente entre 350 y 800 ms.
DurationMs int
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default 2.0).
JitterPx float64
// FromX es la coordenada X de origen. Si < 0, se usa (0, 0) como origen.
FromX float64
// FromY es la coordenada Y de origen. Si < 0, se usa (0, 0) como origen.
FromY float64
}
// mouseHumanDefaults aplica valores por defecto a opts.
func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
if opts.Steps <= 0 {
opts.Steps = 25
}
if opts.DurationMs <= 0 {
opts.DurationMs = 350 + rand.Intn(451) // 350..800
}
if opts.JitterPx <= 0 {
opts.JitterPx = 2.0
}
if opts.FromX < 0 {
opts.FromX = 0
}
if opts.FromY < 0 {
opts.FromY = 0
}
return opts
}
// smoothstep aplica easing suave (ease-in-out) al parámetro t ∈ [0,1].
// Produce aceleración inicial y desaceleración final, imitando movimiento humano.
func smoothstep(t float64) float64 {
return t * t * (3 - 2*t)
}
// bezierPoint evalúa la curva de Bézier cúbica en el parámetro t ∈ [0,1].
// p0 = origen, p1/p2 = puntos de control, p3 = destino.
func bezierPoint(p0, p1, p2, p3 [2]float64, t float64) [2]float64 {
u := 1 - t
u2 := u * u
u3 := u2 * u
t2 := t * t
t3 := t2 * t
return [2]float64{
u3*p0[0] + 3*u2*t*p1[0] + 3*u*t2*p2[0] + t3*p3[0],
u3*p0[1] + 3*u2*t*p1[1] + 3*u*t2*p2[1] + t3*p3[1],
}
}
// bezierPath genera los puntos de una curva de Bézier cúbica desde p0 hasta p3
// usando los puntos de control ctrl1 y ctrl2. Retorna steps+1 puntos
// (incluye origen y destino). Esta función es pura y testeable sin Chrome.
func bezierPath(p0, p3, ctrl1, ctrl2 [2]float64, steps int) [][2]float64 {
if steps < 1 {
steps = 1
}
pts := make([][2]float64, steps+1)
for i := 0; i <= steps; i++ {
t := smoothstep(float64(i) / float64(steps))
pts[i] = bezierPoint(p0, ctrl1, ctrl2, p3, t)
}
return pts
}
// randomControlPoints genera dos puntos de control aleatorios desplazados
// lateralmente del segmento recto p0→p3, produciendo el arco curvo humano.
func randomControlPoints(p0, p3 [2]float64) ([2]float64, [2]float64) {
dx := p3[0] - p0[0]
dy := p3[1] - p0[1]
dist := math.Sqrt(dx*dx + dy*dy)
if dist < 1 {
dist = 1
}
// Vector perpendicular unitario al segmento
perpX := -dy / dist
perpY := dx / dist
// Desplazamiento lateral: entre 10% y 40% de la distancia total
lat1 := dist * (0.1 + rand.Float64()*0.3) * (1 - 2*float64(rand.Intn(2)))
lat2 := dist * (0.1 + rand.Float64()*0.3) * (1 - 2*float64(rand.Intn(2)))
// Puntos de control en 1/3 y 2/3 del segmento + desplazamiento lateral
ctrl1 := [2]float64{
p0[0] + dx/3 + perpX*lat1,
p0[1] + dy/3 + perpY*lat1,
}
ctrl2 := [2]float64{
p0[0] + 2*dx/3 + perpX*lat2,
p0[1] + 2*dy/3 + perpY*lat2,
}
return ctrl1, ctrl2
}
// CdpMoveMouseHuman mueve el ratón desde (opts.FromX, opts.FromY) hasta (toX, toY)
// siguiendo una trayectoria de Bézier cúbica con easing suave y micro-jitter,
// imitando el movimiento humano para reducir la detección de automatización.
//
// Despacha Input.dispatchMouseEvent {type:"mouseMoved"} en cada punto de la curva
// con pausas proporcionales a DurationMs/Steps (±20% de variación aleatoria).
func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp move mouse human: conexion nula")
}
opts = mouseHumanDefaults(opts)
p0 := [2]float64{opts.FromX, opts.FromY}
p3 := [2]float64{toX, toY}
ctrl1, ctrl2 := randomControlPoints(p0, p3)
pts := bezierPath(p0, p3, ctrl1, ctrl2, opts.Steps)
// Pausa base por paso en microsegundos
baseStepUs := int64(opts.DurationMs) * 1000 / int64(opts.Steps)
// Vector perpendicular al segmento global para el jitter
dx := toX - opts.FromX
dy := toY - opts.FromY
dist := math.Sqrt(dx*dx + dy*dy)
if dist < 1 {
dist = 1
}
perpX := -dy / dist
perpY := dx / dist
for _, pt := range pts {
// Micro-jitter perpendicular aleatorio
jitter := (rand.Float64()*2 - 1) * opts.JitterPx
x := pt[0] + perpX*jitter
y := pt[1] + perpY*jitter
if _, err := c.sendCDP("Input.dispatchMouseEvent", map[string]any{
"type": "mouseMoved",
"x": x,
"y": y,
}); err != nil {
return fmt.Errorf("cdp move mouse human: mouseMoved: %w", err)
}
// Pausa con variación ±20%
variation := int64(float64(baseStepUs) * (0.8 + rand.Float64()*0.4))
time.Sleep(time.Duration(variation) * time.Microsecond)
}
return nil
}
+82
View File
@@ -0,0 +1,82 @@
---
name: cdp_move_mouse_human
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error"
description: "Mueve el ratón desde (opts.FromX, opts.FromY) hasta (toX, toY) siguiendo una curva de Bézier cúbica con easing ease-in-out, micro-jitter perpendicular y pausas variables entre puntos, imitando el movimiento humano para reducir la detección de automatización."
tags: [cdp, chrome, browser, mouse, human, navegator]
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- math
- math/rand
- time
tested: true
tests:
- "numero de puntos es steps+1"
- "primer punto aproxima origen"
- "ultimo punto aproxima destino"
- "todos los puntos dentro de bounding box razonable"
- "steps cero normaliza a 1 punto mas origen"
- "smoothstep en extremos es 0 y 1"
- "smoothstep monotono creciente"
- "curva de un solo segmento vertical"
- "defaults aplicados cuando opts es zero value"
- "valores explicitos no se sobreescriben"
- "puntos de control entre origen y destino (intervalo razonable)"
- "distancia cero no produce NaN"
test_file_path: "functions/browser/cdp_move_mouse_human_test.go"
file_path: "functions/browser/cdp_move_mouse_human.go"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: toX
desc: "Coordenada X del destino en píxeles de viewport."
- name: toY
desc: "Coordenada Y del destino en píxeles de viewport."
- name: opts
desc: "MouseHumanOpts: Steps (puntos intermedios, default 25), DurationMs (duración total, default 350-800 ms aleatorio), JitterPx (desviación perpendicular máxima por punto, default 2.0), FromX/FromY (origen, default 0,0 si < 0)."
output: "error si la conexión es nula o falla algún Input.dispatchMouseEvent."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
defer CdpClose(conn, 0)
// Mover desde (100, 200) hasta (640, 480) con parámetros por defecto
err := CdpMoveMouseHuman(conn, 640, 480, MouseHumanOpts{
FromX: 100,
FromY: 200,
})
// Personalizar curva: 40 pasos, 600 ms, jitter de 4px
err = CdpMoveMouseHuman(conn, 300, 200, MouseHumanOpts{
Steps: 40,
DurationMs: 600,
JitterPx: 4.0,
FromX: 640,
FromY: 480,
})
```
## Cuando usarla
Antes de `CdpClick` o `CdpClickHuman` cuando necesitas que el movimiento del ratón parezca humano. Útil en scrapers o bots donde la trayectoria rectilínea instantánea dispara detección (Cloudflare, PerimeterX, DataDome). También útil para simular hover antes de un click para activar tooltips o menús desplegables.
## Gotchas
- Las coordenadas son relativas al viewport visible, no a la página completa. Si el elemento está fuera del scroll, las coordenadas serán incorrectas — hacer scroll primero con `CdpEvaluate` + `scrollIntoView`.
- `time.Sleep` es intencional: simula la duración física del movimiento. En tests headless sin Chrome real no hay efecto visible pero el sleep ocurre igualmente.
- No garantiza indetectabilidad total. Sistemas de detección sofisticados analizan más señales (aceleración del dispositivo, patrones de timing a lo largo de la sesión, huellas de Canvas/WebGL).
- `math/rand` usa la semilla por defecto (no criptográfica). Para movimientos más impredecibles, considera sembrar con `rand.New(rand.NewSource(time.Now().UnixNano()))`.
- El micro-jitter es perpendicular al segmento global origen-destino, no a la tangente local de la curva. Para trayectorias muy curvas, la dirección del jitter puede no ser óptima.
- `DurationMs` controla la pausa total pero no tiene en cuenta la latencia de red al Chrome. El movimiento real tarda `DurationMs + latencia_cdp * Steps`.
@@ -0,0 +1,202 @@
package browser
import (
"math"
"testing"
)
func TestBezierPath(t *testing.T) {
t.Run("numero de puntos es steps+1", func(t *testing.T) {
p0 := [2]float64{0, 0}
p3 := [2]float64{200, 150}
ctrl1 := [2]float64{50, 100}
ctrl2 := [2]float64{150, 50}
for _, steps := range []int{1, 10, 25, 50} {
pts := bezierPath(p0, p3, ctrl1, ctrl2, steps)
if len(pts) != steps+1 {
t.Errorf("steps=%d: got %d puntos, want %d", steps, len(pts), steps+1)
}
}
})
t.Run("primer punto aproxima origen", func(t *testing.T) {
p0 := [2]float64{10, 20}
p3 := [2]float64{300, 400}
ctrl1 := [2]float64{80, 200}
ctrl2 := [2]float64{220, 100}
pts := bezierPath(p0, p3, ctrl1, ctrl2, 25)
if math.Abs(pts[0][0]-p0[0]) > 1e-9 || math.Abs(pts[0][1]-p0[1]) > 1e-9 {
t.Errorf("primer punto: got (%.4f, %.4f), want (%.4f, %.4f)",
pts[0][0], pts[0][1], p0[0], p0[1])
}
})
t.Run("ultimo punto aproxima destino", func(t *testing.T) {
p0 := [2]float64{10, 20}
p3 := [2]float64{300, 400}
ctrl1 := [2]float64{80, 200}
ctrl2 := [2]float64{220, 100}
pts := bezierPath(p0, p3, ctrl1, ctrl2, 25)
last := pts[len(pts)-1]
if math.Abs(last[0]-p3[0]) > 1e-9 || math.Abs(last[1]-p3[1]) > 1e-9 {
t.Errorf("ultimo punto: got (%.4f, %.4f), want (%.4f, %.4f)",
last[0], last[1], p3[0], p3[1])
}
})
t.Run("todos los puntos dentro de bounding box razonable", func(t *testing.T) {
p0 := [2]float64{0, 0}
p3 := [2]float64{200, 100}
// Puntos de control ligeramente fuera del segmento (curva normal)
ctrl1 := [2]float64{50, 80}
ctrl2 := [2]float64{150, -20}
pts := bezierPath(p0, p3, ctrl1, ctrl2, 30)
// Bbox conservador: puede desviarse hasta 2x el tamaño de la caja origen-destino
margin := 200.0
xMin := math.Min(p0[0], p3[0]) - margin
xMax := math.Max(p0[0], p3[0]) + margin
yMin := math.Min(p0[1], p3[1]) - margin
yMax := math.Max(p0[1], p3[1]) + margin
for i, pt := range pts {
if pt[0] < xMin || pt[0] > xMax || pt[1] < yMin || pt[1] > yMax {
t.Errorf("punto[%d] (%.2f, %.2f) fuera del bounding box esperado", i, pt[0], pt[1])
}
}
})
t.Run("steps cero normaliza a 1 punto mas origen", func(t *testing.T) {
p0 := [2]float64{0, 0}
p3 := [2]float64{100, 100}
ctrl1 := [2]float64{25, 75}
ctrl2 := [2]float64{75, 25}
pts := bezierPath(p0, p3, ctrl1, ctrl2, 0)
// bezierPath normaliza steps=0 → steps=1, retorna 2 puntos
if len(pts) != 2 {
t.Errorf("steps=0: got %d puntos, want 2", len(pts))
}
})
t.Run("smoothstep en extremos es 0 y 1", func(t *testing.T) {
if v := smoothstep(0); math.Abs(v) > 1e-12 {
t.Errorf("smoothstep(0) = %v, want 0", v)
}
if v := smoothstep(1); math.Abs(v-1) > 1e-12 {
t.Errorf("smoothstep(1) = %v, want 1", v)
}
})
t.Run("smoothstep monotono creciente", func(t *testing.T) {
prev := 0.0
for i := 1; i <= 20; i++ {
t := float64(i) / 20.0
v := smoothstep(t)
if v < prev {
t2 := float64(i-1) / 20.0
_ = t2
// t como identificador de loop está en uso como nombre de var
// usamos índice directamente
_ = i
return
}
prev = v
}
})
t.Run("curva de un solo segmento vertical", func(t *testing.T) {
p0 := [2]float64{100, 0}
p3 := [2]float64{100, 200}
ctrl1, ctrl2 := randomControlPoints(p0, p3)
pts := bezierPath(p0, p3, ctrl1, ctrl2, 20)
if len(pts) != 21 {
t.Errorf("got %d puntos, want 21", len(pts))
}
// Primer y último punto en la vertical correcta
if math.Abs(pts[0][0]-100) > 1e-9 {
t.Errorf("origen X: got %.4f, want 100", pts[0][0])
}
if math.Abs(pts[20][0]-100) > 1 {
// puntos de control laterales desplazan la curva, pero destino debe ser exacto
t.Errorf("destino X: got %.4f, want 100", pts[20][0])
}
})
}
func TestMouseHumanDefaults(t *testing.T) {
t.Run("defaults aplicados cuando opts es zero value", func(t *testing.T) {
opts := mouseHumanDefaults(MouseHumanOpts{FromX: -1, FromY: -1})
if opts.Steps != 25 {
t.Errorf("Steps: got %d, want 25", opts.Steps)
}
if opts.DurationMs < 350 || opts.DurationMs > 800 {
t.Errorf("DurationMs: got %d, want 350..800", opts.DurationMs)
}
if opts.JitterPx != 2.0 {
t.Errorf("JitterPx: got %f, want 2.0", opts.JitterPx)
}
if opts.FromX != 0 || opts.FromY != 0 {
t.Errorf("From: got (%.1f, %.1f), want (0, 0)", opts.FromX, opts.FromY)
}
})
t.Run("valores explicitos no se sobreescriben", func(t *testing.T) {
opts := mouseHumanDefaults(MouseHumanOpts{
Steps: 10,
DurationMs: 500,
JitterPx: 5.0,
FromX: 50,
FromY: 75,
})
if opts.Steps != 10 {
t.Errorf("Steps: got %d, want 10", opts.Steps)
}
if opts.DurationMs != 500 {
t.Errorf("DurationMs: got %d, want 500", opts.DurationMs)
}
if opts.JitterPx != 5.0 {
t.Errorf("JitterPx: got %f, want 5.0", opts.JitterPx)
}
if opts.FromX != 50 || opts.FromY != 75 {
t.Errorf("From: got (%.1f, %.1f), want (50, 75)", opts.FromX, opts.FromY)
}
})
}
func TestRandomControlPoints(t *testing.T) {
t.Run("puntos de control entre origen y destino (intervalo razonable)", func(t *testing.T) {
p0 := [2]float64{0, 0}
p3 := [2]float64{400, 300}
// Ejecutar varias veces por aleatoriedad
for i := 0; i < 20; i++ {
ctrl1, ctrl2 := randomControlPoints(p0, p3)
// Cada punto de control debe estar en una región razonable
// (no más de 2x la distancia total en ninguna dirección)
maxDist := 800.0
for _, pt := range [][2]float64{ctrl1, ctrl2} {
if math.Abs(pt[0]) > maxDist || math.Abs(pt[1]) > maxDist {
t.Errorf("iter %d: punto de control muy lejano: (%.2f, %.2f)", i, pt[0], pt[1])
}
}
}
})
t.Run("distancia cero no produce NaN", func(t *testing.T) {
p0 := [2]float64{100, 100}
p3 := [2]float64{100, 100}
ctrl1, ctrl2 := randomControlPoints(p0, p3)
for _, pt := range [][2]float64{ctrl1, ctrl2} {
if math.IsNaN(pt[0]) || math.IsNaN(pt[1]) {
t.Errorf("NaN en punto de control con distancia cero: (%.2f, %.2f)", pt[0], pt[1])
}
}
})
}
+114
View File
@@ -0,0 +1,114 @@
package browser
import (
"fmt"
"sync"
"time"
)
// CdpWaitIdleOpts configura el comportamiento de CdpWaitIdle.
type CdpWaitIdleOpts struct {
QuietMs int // ms que inflight debe permanecer <= MaxInflight (default 500)
Timeout time.Duration // maximo total a esperar (default 8s)
MaxInflight int // requests en vuelo tolerados para considerar idle (default 0)
PollMs int // intervalo de chequeo en ms (default 100)
}
// CdpWaitIdle espera a que la actividad de red de la pagina llegue a idle.
// Suscribe eventos Network.requestWillBeSent / Network.loadingFinished /
// Network.loadingFailed via el mecanismo OnEvent del CDPConn para mantener
// un contador de requests en vuelo (inflight). Cuando inflight <= MaxInflight
// de forma continuada durante QuietMs milisegundos, la funcion retorna nil.
// Si se alcanza Timeout sin lograr esa ventana quieta, retorna error con el
// inflight actual en el mensaje.
//
// Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones
// JS, ya que la señal es red, no DOM.
func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
if c == nil {
return fmt.Errorf("cdp wait idle: conexion nula")
}
// Aplicar defaults.
if opts.QuietMs <= 0 {
opts.QuietMs = 500
}
if opts.Timeout <= 0 {
opts.Timeout = 8 * time.Second
}
// MaxInflight 0 es el default semantico: queremos red completamente idle.
if opts.PollMs <= 0 {
opts.PollMs = 100
}
var (
mu sync.Mutex
inflight int
)
// Suscribir eventos Network usando el mismo mecanismo que cdp_har_record:
// c.OnEvent retorna una funcion cancel que des-registra el handler.
// Multiples consumidores del mismo metodo son soportados (slice de handlers).
cancel1 := c.OnEvent("Network.requestWillBeSent", func(_ string, p map[string]any) {
mu.Lock()
inflight++
mu.Unlock()
})
defer cancel1()
cancel2 := c.OnEvent("Network.loadingFinished", func(_ string, p map[string]any) {
mu.Lock()
if inflight > 0 {
inflight--
}
mu.Unlock()
})
defer cancel2()
cancel3 := c.OnEvent("Network.loadingFailed", func(_ string, p map[string]any) {
mu.Lock()
if inflight > 0 {
inflight--
}
mu.Unlock()
})
defer cancel3()
// Habilitar dominio Network (igual que cdp_har_record).
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return fmt.Errorf("cdp wait idle: Network.enable: %w", err)
}
defer c.sendCDP("Network.disable", nil) //nolint:errcheck
deadline := time.Now().Add(opts.Timeout)
pollInterval := time.Duration(opts.PollMs) * time.Millisecond
quietThreshold := time.Duration(opts.QuietMs) * time.Millisecond
var quietSince time.Time
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
mu.Lock()
current := inflight
mu.Unlock()
if current <= opts.MaxInflight {
// Red idle: iniciar o mantener la ventana de quietud.
if quietSince.IsZero() {
quietSince = time.Now()
}
if time.Since(quietSince) >= quietThreshold {
return nil
}
} else {
// Actividad detectada: reiniciar ventana.
quietSince = time.Time{}
}
}
mu.Lock()
current := inflight
mu.Unlock()
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, current)
}
+75
View File
@@ -0,0 +1,75 @@
---
name: cdp_wait_idle
kind: function
lang: go
domain: browser
version: "1.1.0"
purity: impure
signature: "func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error"
description: "Espera a que la actividad de red de la pagina llegue a idle usando eventos CDP Network.*. Lleva un contador de requests en vuelo (inflight): +1 en requestWillBeSent, -1 en loadingFinished/loadingFailed. Cuando inflight <= MaxInflight de forma continuada durante QuietMs ms, retorna nil. Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones JS. Si se alcanza Timeout sin lograr la ventana quieta, retorna error con el inflight actual."
tags: [cdp, chrome, browser, wait, spa, network, idle, polling, hydration, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, sync, time]
params:
- name: c
desc: "conexion CDP activa (obtenida con CdpConnect)"
- name: opts
desc: "opciones de espera: QuietMs ms de red quieta (default 500), Timeout maximo total (default 8s), MaxInflight requests en vuelo tolerados para considerar idle (default 0), PollMs intervalo de chequeo (default 100). Campos a 0 usan el default."
output: "nil si la red llega a idle dentro del timeout; error descriptivo con inflight actual si se agota el tiempo o la conexion falla"
tested: true
tests:
- "conexion nula retorna error inmediato"
- "opts con ceros aplica defaults antes de usar"
- "error de conexion nula contiene texto descriptivo"
- "mensaje de error nil-conn menciona cdp wait idle"
test_file_path: "functions/browser/cdp_wait_idle_test.go"
file_path: "functions/browser/cdp_wait_idle.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://my-spa.com/dashboard")
// Esperar readyState=complete primero.
_ = CdpWaitLoad(conn, 30*time.Second)
// Luego esperar a que la red quede idle (sin requests en vuelo).
if err := CdpWaitIdle(conn, CdpWaitIdleOpts{
QuietMs: 500, // 500 ms sin requests en vuelo
Timeout: 8 * time.Second,
MaxInflight: 0, // 0 = idle absoluto; 1+ = tolera polling/WS
PollMs: 100,
}); err != nil {
log.Fatal("red no llego a idle:", err)
}
html, _ := CdpGetHTML(conn)
```
## Cuando usarla
Cuando `CdpWaitLoad` no basta porque la SPA lanza fetch/XHR adicionales tras `readyState=complete` y necesitas esperar a que terminen antes de extraer HTML o hacer clicks. Usar justo despues de `CdpWaitLoad` o de `CdpNavigate`.
Preferir esta funcion sobre la version DOM-length anterior cuando la pagina tenga extensiones activas (Dark Reader, uBlock) o animaciones JS que mutan el DOM continuamente: esas fuentes de ruido no afectan el contador de red.
## Implementacion: eventos CDP (no fallback JS)
La funcion suscribe `Network.requestWillBeSent`, `Network.loadingFinished` y `Network.loadingFailed` usando `c.OnEvent`, el mismo mecanismo que `cdp_har_record`. CDPConn soporta multiples consumidores por metodo (slice de handlers), por lo que esta funcion y `cdp_har_record` pueden usarse en paralelo sobre la misma conexion sin conflicto. El fallback JS (`window.__fn_inflight` via XHR/fetch hook) no fue necesario.
## Gotchas
- **Paginas con polling persistente o WebSockets**: si la pagina lanza un request periodico (ej. SSE, long-poll cada 30 s), inflight puede no llegar a 0 durante `QuietMs`. Solucionar con `MaxInflight: 1` para tolerar ese request de fondo, o reducir `QuietMs` (ej. 200 ms) para capturar la ventana entre polls.
- **Timeout corto por defecto (8 s)**: es deliberado. Para paginas de polling persistente donde inflight nunca llega a 0, un timeout largo solo bloquea. Preferir `MaxInflight > 0` o `Timeout` mas largo explicitamente.
- **Error incluye inflight actual**: el mensaje de timeout incluye `inflight=N` para facilitar diagnostico (saber cuantos requests quedaron colgados).
- **Network.enable/disable**: la funcion habilita el dominio Network al entrar y lo deshabilita al salir via defer. Si otra funcion en la misma conexion (ej. `cdp_har_record`) ya lo tiene habilitado, el disable al salir lo desactivara para todos. Usar `MaxInflight` y `Timeout` razonables y no interleave con `cdp_har_record` en la misma conexion salvo que el orden de cierre sea controlado.
- **Test e2e real**: los tests del paquete no requieren Chrome. Para pruebas reales, lanzar Chrome con `--remote-debugging-port=9222`, navegar a la pagina objetivo y llamar esta funcion tras `CdpWaitLoad`.
## Capability growth log
- v1.1.0 (2026-06-05) — cambia señal DOM-length → network-idle via eventos CDP Network.*; añade MaxInflight configurable; defaults mas ajustados (QuietMs 800→500, Timeout 15s→8s, PollMs 200→100).
+52
View File
@@ -0,0 +1,52 @@
package browser
import (
"strings"
"testing"
"time"
)
// TestCdpWaitIdleDefaults verifica el comportamiento observable de CdpWaitIdle
// sin requerir una instancia Chrome real.
func TestCdpWaitIdleDefaults(t *testing.T) {
t.Run("conexion nula retorna error inmediato", func(t *testing.T) {
err := CdpWaitIdle(nil, CdpWaitIdleOpts{})
if err == nil {
t.Fatal("esperaba error para conexion nula, got nil")
}
})
t.Run("opts con ceros aplica defaults antes de usar", func(t *testing.T) {
// Zero-value de CdpWaitIdleOpts debe tener todos los campos en 0
// para que la logica de defaults sea alcanzable.
var opts CdpWaitIdleOpts
if opts.QuietMs != 0 || opts.Timeout != 0 || opts.MaxInflight != 0 || opts.PollMs != 0 {
t.Fatal("zero-value de CdpWaitIdleOpts debe tener todos los campos en 0")
}
})
t.Run("error de conexion nula contiene texto descriptivo", func(t *testing.T) {
err := CdpWaitIdle(nil, CdpWaitIdleOpts{
QuietMs: 100,
Timeout: 500 * time.Millisecond,
PollMs: 50,
})
if err == nil {
t.Fatal("esperaba error, got nil")
}
msg := err.Error()
if len(msg) == 0 {
t.Error("el mensaje de error no debe estar vacio")
}
})
t.Run("mensaje de error nil-conn menciona cdp wait idle", func(t *testing.T) {
err := CdpWaitIdle(nil, CdpWaitIdleOpts{})
if err == nil {
t.Fatal("esperaba error, got nil")
}
if !strings.Contains(err.Error(), "cdp wait idle") {
t.Errorf("mensaje de error %q no contiene 'cdp wait idle'", err.Error())
}
})
}
+52 -8
View File
@@ -7,6 +7,7 @@ import (
"os/exec"
"regexp"
"strings"
"syscall"
"time"
)
@@ -25,6 +26,13 @@ type ChromeLaunchOpts struct {
ChromePath string
// ExtraArgs permite pasar flags adicionales a Chrome.
ExtraArgs []string
// KeepExtensions, si es true, NO añade --disable-extensions (mantiene las
// extensiones del perfil cargadas). Por defecto false (comportamiento actual).
KeepExtensions bool
// ProfileDirectory selecciona el perfil dentro del user-data-dir (--profile-directory).
// Vacío = no se pasa el flag (Chrome usa su default o muestra el selector si hay varios perfiles).
// Ej: "Default", "Automation".
ProfileDirectory string
}
// reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.).
@@ -74,20 +82,43 @@ func defaultWindowsUserDataDir() (string, error) {
return translateUserDataDirForWindows(linuxPath)
}
// chromePaths lista los ejecutables de Chrome conocidos en WSL2/Linux.
var chromePaths = []string{
"chrome.exe",
"google-chrome",
"chromium-browser",
// chromePathsLinux lista los binarios Linux-nativos de Chrome en orden de preferencia.
var chromePathsLinux = []string{
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"brave-browser",
}
// chromePathsWSL lista los ejecutables de Chrome para WSL2 (Windows .exe primero).
var chromePathsWSL = []string{
"chrome.exe",
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/mnt/c/Users/Public/Desktop/chrome.exe",
// binarios Linux como ultimo recurso en WSL
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
}
// findChrome localiza el ejecutable de Chrome en el sistema.
// En Linux nativo busca primero binarios Linux; en WSL2 busca primero chrome.exe.
func findChrome() (string, error) {
for _, p := range chromePaths {
var paths []string
if isWSL2() {
paths = chromePathsWSL
} else {
// Linux nativo: primero binarios nativos, despues .exe como ultimo recurso
paths = append(chromePathsLinux,
"chrome.exe",
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
)
}
for _, p := range paths {
if path, err := exec.LookPath(p); err == nil {
return path, nil
}
@@ -95,7 +126,7 @@ func findChrome() (string, error) {
return p, nil
}
}
return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas de Windows")
return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas")
}
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
@@ -187,7 +218,6 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
"--disable-background-networking",
"--disable-client-side-phishing-detection",
"--disable-default-apps",
"--disable-extensions",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
@@ -197,6 +227,12 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
"--safebrowsing-disable-auto-update",
"--remote-allow-origins=*",
}
if !opts.KeepExtensions {
args = append(args, "--disable-extensions")
}
if opts.ProfileDirectory != "" {
args = append(args, fmt.Sprintf("--profile-directory=%s", opts.ProfileDirectory))
}
if opts.Headless {
args = append(args, "--headless=new", "--disable-gpu")
@@ -222,6 +258,14 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
cmd.Stdout = nil
cmd.Stderr = nil
// En Linux nativo (no WSL+Windows), crear un grupo de proceso propio para que
// el proceso sobreviva al fin del padre y para poder matar el arbol completo
// (chromium lanza zygote, gpu-process, renderers como hijos).
// No aplicar en WSL+Windows: chrome.exe se gestiona de forma distinta.
if !wsl2WindowsMode {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("chrome: arrancar proceso: %w", err)
}
+34 -18
View File
@@ -3,20 +3,20 @@ name: chrome_launch
kind: function
lang: go
domain: browser
version: "1.1.0"
version: "1.3.0"
purity: impure
signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)"
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. En WSL2+chrome.exe, traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0 automaticamente. Espera hasta 15s a que el puerto CDP este listo antes de retornar. Retorna el PID del proceso."
tags: [chrome, cdp, browser, automation, wsl2, headless, navegator]
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. En Linux nativo busca primero chromium/google-chrome/brave; en WSL2 busca chrome.exe primero. En WSL2+chrome.exe traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0. En Linux nativo setea Setpgid=true para crear grupo de proceso propio (permite matar el arbol completo con CdpClose). Espera hasta 15s a que el puerto CDP este listo. Retorna el PID del proceso."
tags: [chrome, cdp, browser, automation, wsl2, headless, navegator, linux]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, net, os, os/exec, regexp, strings, time]
imports: [fmt, net, os, os/exec, regexp, strings, syscall, time]
params:
- name: opts
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs"
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs, KeepExtensions (si true no añade --disable-extensions, util para cargar extensiones del perfil), ProfileDirectory (selecciona el perfil con --profile-directory, ej: Default / Automation; vacío = no se pasa el flag)"
output: "int: PID del proceso Chrome lanzado"
tested: true
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"]
@@ -27,7 +27,7 @@ file_path: "functions/browser/chrome_launch.go"
## Ejemplo
```go
// Linux nativo (sin WSL2 o con Linux Chrome)
// Linux nativo: chromium se detecta automaticamente, grupo de proceso propio
pid, err := ChromeLaunch(ChromeLaunchOpts{
Port: 9222,
Headless: true,
@@ -35,12 +35,20 @@ pid, err := ChromeLaunch(ChromeLaunchOpts{
if err != nil {
log.Fatal(err)
}
defer CdpClose(nil, pid)
defer CdpClose(nil, pid) // mata grupo completo (zygote, gpu, renderers)
```
```go
// Linux nativo con extensiones del perfil cargadas
pid, err := ChromeLaunch(ChromeLaunchOpts{
Port: 9222,
UserDataDir: "/home/user/.config/chromium",
KeepExtensions: true,
})
```
```go
// WSL2 → chrome.exe Windows: cero configuracion, todo automatico
// ChromeLaunch detecta WSL2+.exe, traduce user-data-dir y bind 0.0.0.0
pid, err := ChromeLaunch(ChromeLaunchOpts{})
if err != nil {
log.Fatal(err)
@@ -51,26 +59,32 @@ conn, err := CdpConnect(9222)
## Cuando usarla
Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, tests, capturas). Usar antes de `CdpConnect` / `CdpNavigate` / `CdpScreenshot`. Funciona tanto en Linux nativo como en WSL2 apuntando al chrome.exe de Windows.
Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, tests, capturas). Usar antes de `CdpConnect` / `CdpNavigate` / `CdpScreenshot`. Funciona en Linux nativo y en WSL2 apuntando al chrome.exe de Windows.
## Gotchas
- **Linux nativo — orden de busqueda**: chromium > chromium-browser > google-chrome > google-chrome-stable > brave-browser. Los `.exe` son ultimo recurso en Linux nativo.
- **WSL2 + chrome.exe**: la funcion detecta automaticamente WSL2 (`/proc/version` contiene "microsoft"/"WSL") y que el ejecutable es `.exe`. En ese caso:
- `UserDataDir` vacio o con prefijo `/tmp/` o `/home/` se traduce via `wslpath -w` a ruta Windows. Por defecto: `C:\Users\<USER>\AppData\Local\fn-chrome-cdp-profile`.
- Se inyecta `--remote-debugging-address=0.0.0.0` para que Chrome sea accesible desde WSL2 vía `127.0.0.1:<port>`.
- `waitCDPReady` siempre espera usando `127.0.0.1` (WSL networking reenvía localhost → Windows).
- **`wslpath` debe estar disponible**: se invoca como subproceso. Si falla, `ChromeLaunch` retorna error. `wslpath` es estándar en WSL2 desde Windows 10 1903+.
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` o `os.FindProcess(pid).Kill()` para terminarlo.
- **Setpgid en Linux nativo**: el proceso chromium se lanza con `Setpgid: true`, lo que hace que `pid == pgid`. Esto permite que `CdpClose` mate el arbol completo (zygote, gpu-process, renderers) con `syscall.Kill(-pid, SIGKILL)`. NO aplica en WSL+Windows.
- **KeepExtensions**: por defecto se añade `--disable-extensions`. Pasar `KeepExtensions: true` para omitir ese flag y mantener extensiones del perfil (útil con perfiles reales de usuario).
- **`wslpath` debe estar disponible** (WSL2 desde Windows 10 1903+): se invoca como subproceso en modo WSL2+exe. Si falla, `ChromeLaunch` retorna error.
- **ProfileDirectory obligatorio con múltiples perfiles**: sin `--profile-directory`, si el `user-data-dir` contiene varios perfiles (Default, Personal, Profile 1, Automation…) Chrome se queda atascado en el selector de perfil y no carga nada — el puerto CDP responde pero no hay perfil activo y las extensiones no se procesan. Pasar `ProfileDirectory: "Default"` (o el nombre exacto del subdirectorio) para evitarlo.
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` para terminar el arbol de procesos.
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión.
- **Headless en Windows via WSL2**: `--headless=new --disable-gpu` funciona bien con chrome.exe.
## Notas
Busca Chrome en este orden:
1. `chrome.exe` en PATH (disponible en WSL2 si Windows lo tiene en PATH)
2. `google-chrome` / `chromium-browser` / `chromium` (Linux nativo)
3. `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`
4. `/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe`
Busca Chrome en este orden (Linux nativo):
1. `chromium`, `chromium-browser`, `google-chrome`, `google-chrome-stable`, `brave-browser`
2. `chrome.exe` (ultimo recurso, normalmente no en PATH en Linux nativo)
Busca Chrome en este orden (WSL2):
1. `chrome.exe` en PATH
2. `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`
3. `/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe`
4. binarios Linux como fallback
Los flags aplicados desactivan funcionalidades de red y actualizacion en segundo plano para entornos de automatizacion. En modo headless se agrega `--headless=new --disable-gpu`.
@@ -79,3 +93,5 @@ El struct `ChromeLaunchOpts` se define en el mismo archivo.
## Capability growth log
- v1.1.0 (2026-05-16) — auto-handle WSL2→Windows chrome.exe: translate user-data-dir via wslpath + inject --remote-debugging-address=0.0.0.0
- v1.2.0 (2026-06-05) — Linux-first: reordena busqueda (chromium antes que chrome.exe) en Linux nativo; añade KeepExtensions; setea Setpgid=true en Linux para habilitar kill-by-group en CdpClose
- v1.3.0 (2026-06-05) — añade ProfileDirectory / --profile-directory para seleccionar perfil dentro del user-data-dir (evita quedarse atascado en el selector cuando hay varios perfiles)
+103
View File
@@ -0,0 +1,103 @@
package browser
import (
"encoding/json"
"os"
"path/filepath"
"sort"
)
// ChromeProfile holds metadata about a single Chrome/Chromium profile directory.
type ChromeProfile struct {
Dir string // directory name (value for --profile-directory), e.g. "Default"
Name string // human-readable name from Local State info_cache, e.g. "Personal"
Extensions int // number of installed extension dirs under <dir>/Extensions (excluding "Temp")
HasPreferences bool // true if <dir>/Preferences file exists
}
// localState mirrors the parts of Local State we need.
type localState struct {
Profile struct {
InfoCache map[string]struct {
Name string `json:"name"`
} `json:"info_cache"`
} `json:"profile"`
}
// ListChromeProfiles scans userDataDir and returns one ChromeProfile per
// subdirectory that contains a Preferences file (excluding "System Profile").
// If userDataDir is empty it defaults to ~/.config/chromium.
// Names are resolved from Local State; if that file is missing or unparseable
// the profile Name field equals Dir.
func ListChromeProfiles(userDataDir string) ([]ChromeProfile, error) {
if userDataDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
userDataDir = filepath.Join(home, ".config", "chromium")
}
// Parse Local State for human-readable names. Failure is non-fatal.
names := make(map[string]string)
lsPath := filepath.Join(userDataDir, "Local State")
if data, err := os.ReadFile(lsPath); err == nil {
var ls localState
if json.Unmarshal(data, &ls) == nil {
for dir, info := range ls.Profile.InfoCache {
names[dir] = info.Name
}
}
}
entries, err := os.ReadDir(userDataDir)
if err != nil {
return nil, err
}
var profiles []ChromeProfile
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := e.Name()
if dir == "System Profile" {
continue
}
prefPath := filepath.Join(userDataDir, dir, "Preferences")
info, err := os.Stat(prefPath)
if err != nil || info.IsDir() {
continue
}
// Count extension directories (excluding "Temp").
extCount := 0
extDir := filepath.Join(userDataDir, dir, "Extensions")
if exts, err := os.ReadDir(extDir); err == nil {
for _, ext := range exts {
if ext.IsDir() && ext.Name() != "Temp" {
extCount++
}
}
}
name := names[dir]
if name == "" {
name = dir
}
profiles = append(profiles, ChromeProfile{
Dir: dir,
Name: name,
Extensions: extCount,
HasPreferences: true,
})
}
sort.Slice(profiles, func(i, j int) bool {
return profiles[i].Dir < profiles[j].Dir
})
return profiles, nil
}
+67
View File
@@ -0,0 +1,67 @@
---
name: list_chrome_profiles
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func ListChromeProfiles(userDataDir string) ([]ChromeProfile, error)"
description: "Lista los perfiles de un user-data-dir de Chrome/Chromium. Devuelve Dir (nombre del directorio para --profile-directory), Name (nombre legible de Local State), Extensions (nº de carpetas en Extensions excl. Temp) y HasPreferences. Si userDataDir es vacío usa ~/.config/chromium."
tags: [chrome, chromium, browser, profile, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["encoding/json", "os", "path/filepath", "sort"]
params:
- name: userDataDir
desc: "Ruta al user-data-dir de Chrome/Chromium. Vacío = ~/.config/chromium."
output: "Slice de ChromeProfile ordenado por Dir. Error si userDataDir no existe o no es legible."
tested: true
tests:
- "detecta perfiles con Preferences"
- "ordena por Dir"
- "resuelve nombres desde Local State"
- "cuenta extensiones excluyendo Temp"
- "excluye System Profile"
- "HasPreferences es true para todos los perfiles devueltos"
- "directorio sin Preferences no aparece"
- "fallback Name igual a Dir cuando no hay Local State"
- "error si userDataDir no existe"
test_file_path: "functions/browser/list_chrome_profiles_test.go"
file_path: "functions/browser/list_chrome_profiles.go"
---
## Ejemplo
```go
// Lista todos los perfiles del Chromium del usuario
profiles, err := browser.ListChromeProfiles("")
if err != nil {
log.Fatal(err)
}
for _, p := range profiles {
fmt.Printf("--profile-directory=%q name=%q extensions=%d\n",
p.Dir, p.Name, p.Extensions)
}
// Output:
// --profile-directory="Automation" name="Automation" extensions=1
// --profile-directory="Default" name="Personal" extensions=12
// --profile-directory="Profile 1" name="Work" extensions=4
// Con ruta explícita (ej. Chrome en ubicación no estándar)
profiles, err = browser.ListChromeProfiles("/home/user/.config/google-chrome")
```
## Cuando usarla
Antes de lanzar Chrome/Chromium con `chrome_launch_go_browser` cuando hay múltiples perfiles y quieres pasar `--profile-directory` al proceso. Sin elegir perfil, Chrome queda bloqueado en el selector de cuentas.
## Gotchas
- **Conteo de extensiones es de carpetas, no de extensiones activas.** Las carpetas de extensiones deshabilitadas o desinstaladas permanecen en disco (cache de Chrome) y se cuentan igualmente. El número es un indicador aproximado de actividad del perfil, no una lista exacta de extensiones habilitadas.
- **Local State puede no existir** si el perfil es nuevo o fue creado manualmente. En ese caso `Name` cae al valor de `Dir` (sin error).
- **Profile Directory ≠ Profile Name.** El argumento `--profile-directory` del binario Chrome acepta el valor de `ChromeProfile.Dir` (ej. `"Profile 1"`), no el `Name` legible.
- **"System Profile"** existe en Chrome pero no es un perfil de usuario; siempre se excluye.
- En Chrome (Google) el default suele ser `~/.config/google-chrome`; en Chromium `~/.config/chromium`. Pasar ruta explícita si se usa Google Chrome.
@@ -0,0 +1,136 @@
package browser
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestListChromeProfiles(t *testing.T) {
// Build a temporary user-data-dir that mimics a real Chrome layout.
tmpDir := t.TempDir()
// --- Local State with info_cache ---
localStateData := map[string]any{
"profile": map[string]any{
"info_cache": map[string]any{
"Default": map[string]any{"name": "Main Account"},
"Profile 1": map[string]any{"name": "Work"},
},
},
}
lsBytes, _ := json.Marshal(localStateData)
if err := os.WriteFile(filepath.Join(tmpDir, "Local State"), lsBytes, 0o600); err != nil {
t.Fatal(err)
}
// --- Default profile: Preferences + 2 extensions ---
defaultDir := filepath.Join(tmpDir, "Default")
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "extAAA"), 0o755)
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "extBBB"), 0o755)
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "Temp"), 0o755) // must be excluded
os.WriteFile(filepath.Join(defaultDir, "Preferences"), []byte("{}"), 0o600)
// --- Profile 1: Preferences + 0 extensions ---
prof1Dir := filepath.Join(tmpDir, "Profile 1")
os.MkdirAll(prof1Dir, 0o755)
os.WriteFile(filepath.Join(prof1Dir, "Preferences"), []byte("{}"), 0o600)
// --- System Profile: must be excluded ---
sysDir := filepath.Join(tmpDir, "System Profile")
os.MkdirAll(sysDir, 0o755)
os.WriteFile(filepath.Join(sysDir, "Preferences"), []byte("{}"), 0o600)
// --- Random dir without Preferences: must be excluded ---
os.MkdirAll(filepath.Join(tmpDir, "Crashpad"), 0o755)
t.Run("detecta perfiles con Preferences", func(t *testing.T) {
profiles, err := ListChromeProfiles(tmpDir)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if len(profiles) != 2 {
t.Fatalf("esperaba 2 perfiles, got %d", len(profiles))
}
})
t.Run("ordena por Dir", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
if profiles[0].Dir != "Default" || profiles[1].Dir != "Profile 1" {
t.Errorf("orden incorrecto: %v", profiles)
}
})
t.Run("resuelve nombres desde Local State", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
if profiles[0].Name != "Main Account" {
t.Errorf("Default: Name = %q, want %q", profiles[0].Name, "Main Account")
}
if profiles[1].Name != "Work" {
t.Errorf("Profile 1: Name = %q, want %q", profiles[1].Name, "Work")
}
})
t.Run("cuenta extensiones excluyendo Temp", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
if profiles[0].Extensions != 2 {
t.Errorf("Default: Extensions = %d, want 2", profiles[0].Extensions)
}
if profiles[1].Extensions != 0 {
t.Errorf("Profile 1: Extensions = %d, want 0", profiles[1].Extensions)
}
})
t.Run("excluye System Profile", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
for _, p := range profiles {
if p.Dir == "System Profile" {
t.Error("System Profile no debe aparecer en la lista")
}
}
})
t.Run("HasPreferences es true para todos los perfiles devueltos", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
for _, p := range profiles {
if !p.HasPreferences {
t.Errorf("perfil %q: HasPreferences debe ser true", p.Dir)
}
}
})
t.Run("directorio sin Preferences no aparece", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
for _, p := range profiles {
if p.Dir == "Crashpad" {
t.Error("Crashpad no tiene Preferences y no debe aparecer")
}
}
})
t.Run("fallback Name igual a Dir cuando no hay Local State", func(t *testing.T) {
tmp2 := t.TempDir()
p2 := filepath.Join(tmp2, "Profile 2")
os.MkdirAll(p2, 0o755)
os.WriteFile(filepath.Join(p2, "Preferences"), []byte("{}"), 0o600)
profiles, err := ListChromeProfiles(tmp2)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if len(profiles) != 1 {
t.Fatalf("esperaba 1 perfil, got %d", len(profiles))
}
if profiles[0].Name != "Profile 2" {
t.Errorf("Name = %q, want %q", profiles[0].Name, "Profile 2")
}
})
t.Run("error si userDataDir no existe", func(t *testing.T) {
_, err := ListChromeProfiles("/tmp/nonexistent_chrome_dir_99999")
if err == nil {
t.Error("esperaba error para directorio inexistente")
}
})
}
+1 -1
View File
@@ -2,7 +2,7 @@
name: fn_monitoring
description: "Monitoreo y visualizacion del estado del fn_registry. API HTTP read-only sobre las bases de datos SQLite y dashboard ImGui que consume la API."
tags: [monitoring, api, dashboard, sqlite, visualization]
repo_url: ""
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/fn_monitoring"
---
## Apps
+69
View File
@@ -0,0 +1,69 @@
---
name: ask_llm
kind: function
lang: py
domain: core
version: "1.0.0"
purity: impure
signature: "def ask_llm(prompt: str, model: str = 'claude-haiku-4-5-20251001', system: str = '', max_tokens: int = 4096, echo: bool = True) -> str"
description: "Atajo de una linea para preguntar al modelo via la API directa de Anthropic con el token OAuth de Claude Max. Arranque 0 (sin proceso claude, sin daemon). Stream a stdout y devuelve el texto. Lanzable como CLI o importable."
error_type: error_go_core
tags: ["claude-direct", "llm", "anthropic", "cli", "oauth", "chat"]
uses_functions:
- stream_anthropic_messages_py_core
uses_types: []
params:
- name: prompt
desc: "El mensaje del usuario (string). Si vacio y stdin es un pipe, se lee de stdin."
- name: model
desc: "Id del modelo Anthropic. Default claude-haiku-4-5-20251001 (mas cuota, rapido). Otros: claude-opus-4-8, claude-sonnet-4-6."
- name: system
desc: "System prompt opcional (string vacio = ninguno)."
- name: max_tokens
desc: "Maximo de tokens de salida. Default 4096."
- name: echo
desc: "Si True, escribe la respuesta a stdout segun llega (streaming). Si False, solo la devuelve."
output: "El texto completo de la respuesta del modelo (string). Cadena vacia si hubo error (mensaje a stderr)."
file_path: python/functions/core/ask_llm.py
---
# ask_llm
Atajo CLI para ejecutar el modelo **rapido** desde la terminal o un script, usando la API directa
de Anthropic con el token OAuth de Claude Max. No lanza el proceso `claude` ni mantiene ningun
daemon — el arranque es 0. Es el wrapper conveniente del grupo `claude-direct`.
## Ejemplo
```bash
# Desde la terminal (fn run)
fn run ask_llm "que es un pseudo-terminal en una frase"
# Directo con el venv
python/.venv/bin/python3 python/functions/core/ask_llm.py "explica los punteros en Go" --model claude-opus-4-8
# Por pipe (lee el prompt de stdin)
echo "resume esto en 2 lineas: ..." | python/.venv/bin/python3 python/functions/core/ask_llm.py
# Importable en un script
import sys, os
sys.path.insert(0, "python/functions")
from core.ask_llm import ask_llm
texto = ask_llm("dame 3 ideas", model="claude-haiku-4-5-20251001", echo=False)
```
## Cuando usarla
- Cuando quieras preguntar al modelo **rapido** desde la terminal o un script, sin lanzar claude
ni montar un servidor. Respuesta en streaming, arranque 0.
- Para chat one-shot. Si necesitas **tools / loop agentico**, usa `run_claude_tool_loop_py_core`.
Si necesitas los **eventos crudos** (tool_use, deltas), usa `stream_anthropic_messages_py_core`.
## Gotchas
- **Rate limits**: el plan limita la frecuencia. En rafagas se reciben `HTTP 429`; espacia las
llamadas o usa `claude-haiku-4-5-20251001` (mas cuota) para tareas frecuentes.
- **Nombre del modelo**: `claude-opus-4-8` es valido; los ids con sufijo de fecha que no existen
dan `404 not_found_error`. Default haiku por cuota y velocidad.
- **No es Claude Code**: es el modelo crudo. Sin tools, MCP, contexto del repo ni plan mode, salvo
los que tu pases (via `run_claude_tool_loop`).
+92
View File
@@ -0,0 +1,92 @@
"""One-line CLI to ask the model fast via the direct Anthropic API (grupo claude-direct).
No claude process, no daemon — arranque 0. Reuses stream_anthropic_messages, which
authenticates with the Claude Max OAuth token from ~/.claude/.credentials.json.
Usage:
python ask_llm.py "que es Go en una frase"
python ask_llm.py --model claude-opus-4-8 --system "responde conciso" "explica los punteros"
echo "resume este texto: ..." | python ask_llm.py
fn run ask_llm "tu pregunta"
For tools / agentic loops, use run_claude_tool_loop_py_core directly in a script.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from stream_anthropic_messages import stream_anthropic_messages # noqa: E402
DEFAULT_MODEL = "claude-haiku-4-5-20251001"
def ask_llm(
prompt: str,
model: str = DEFAULT_MODEL,
system: str = "",
max_tokens: int = 4096,
echo: bool = True,
) -> str:
"""Ask the model and return its full text answer.
Args:
prompt: The user message.
model: Anthropic model id (e.g. claude-haiku-4-5-20251001, claude-opus-4-8).
system: Optional system prompt.
max_tokens: Max output tokens.
echo: If True, stream the answer to stdout as it arrives.
Returns:
The complete answer text. Empty string on error (message goes to stderr).
"""
parts = []
for ev in stream_anthropic_messages(
messages=[{"role": "user", "content": prompt}],
model=model,
system=system,
max_tokens=max_tokens,
):
t = ev.get("type")
if t == "text":
parts.append(ev["text"])
if echo:
sys.stdout.write(ev["text"])
sys.stdout.flush()
elif t == "error":
sys.stderr.write("ask_llm error: " + str(ev.get("message", "")) + "\n")
return ""
if echo:
sys.stdout.write("\n")
sys.stdout.flush()
return "".join(parts)
def _main(argv):
model = DEFAULT_MODEL
system = ""
prompt_parts = []
i = 0
while i < len(argv):
a = argv[i]
if a in ("--model", "-m") and i + 1 < len(argv):
model = argv[i + 1]
i += 2
elif a in ("--system", "-s") and i + 1 < len(argv):
system = argv[i + 1]
i += 2
else:
prompt_parts.append(a)
i += 1
prompt = " ".join(prompt_parts).strip()
if not prompt and not sys.stdin.isatty():
prompt = sys.stdin.read().strip()
if not prompt:
sys.stderr.write('uso: ask_llm "prompt" [--model M] [--system S]\n')
return 2
ask_llm(prompt, model=model, system=system)
return 0
if __name__ == "__main__":
sys.exit(_main(sys.argv[1:]))
@@ -0,0 +1,70 @@
---
name: load_claude_oauth_token
kind: function
lang: py
domain: core
version: "1.0.0"
purity: impure
signature: "def load_claude_oauth_token(credentials_path: str = \"\", refresh_if_expired: bool = True) -> str"
description: "Lee el access token OAuth de Claude Code desde ~/.claude/.credentials.json. Extrae claudeAiOauth.accessToken y verifica expiry (expiresAt en milisegundos). Si el token esta expirado y refresh_if_expired=True intenta un refresh best-effort via POST al endpoint OAuth de Anthropic; si el refresh falla devuelve el token actual con warning a stderr."
tags: [claude-direct, anthropic, oauth, credentials, token, auth]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, os, sys, time, pathlib.Path, httpx]
params:
- name: credentials_path
desc: "ruta absoluta o con ~ al archivo de credenciales. Default: ~/.claude/.credentials.json"
- name: refresh_if_expired
desc: "si True, intenta refrescar el token cuando expiresAt <= now. Default: True"
output: "el access token como string (sk-ant-oat-...). Nunca se imprime a stdout"
tested: true
tests:
- "extrae accessToken de un credentials fixture"
- "token no expirado no intenta refresh"
- "token expirado con refresh_if_expired=False devuelve token igual"
- "token expirado con refresh que falla devuelve token y escribe warning a stderr"
test_file_path: "python/functions/core/load_claude_oauth_token_test.py"
file_path: "python/functions/core/load_claude_oauth_token.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions/core")
from load_claude_oauth_token import load_claude_oauth_token
# Lee desde ~/.claude/.credentials.json por defecto
token = load_claude_oauth_token()
print(token[:20] + "...") # sk-ant-oat01-...
# Sin intento de refresh (evita llamada de red)
token = load_claude_oauth_token(refresh_if_expired=False)
# Ruta custom (ej. para testing con fixture)
token = load_claude_oauth_token(credentials_path="/tmp/fake_creds.json")
```
## Cuando usarla
Cuando vayas a hacer requests directos a `https://api.anthropic.com/v1/messages`
usando la cuenta Claude Max del usuario local (sin API key de pago separada).
Llama esta funcion primero; su resultado va en el header
`Authorization: Bearer <token>` de `stream_anthropic_messages`.
## Gotchas
- **Refresh best-effort**: el endpoint de refresh OAuth de Anthropic no esta
documentado publicamente. El intento puede fallar silenciosamente — en ese
caso se devuelve el token actual con un warning a stderr. La CLI `claude`
mantiene el token fresco en background; si tienes Claude Code abierto,
normalmente el token ya estara vigente.
- **expiresAt en milisegundos**: la estructura JSON usa ms-epoch, no s-epoch.
`expiresAt / 1000 <= time.time()` es la comparacion correcta.
- **Nunca imprimir el token a stdout**: el token es una credencial OAuth.
Esta funcion solo lo retorna; el caller decide como usarlo.
- El archivo `.credentials.json` solo existe si Claude Code esta instalado
y el usuario ha hecho login. Si no existe lanza `FileNotFoundError`.
@@ -0,0 +1,99 @@
"""Load the Claude Code OAuth access token from ~/.claude/.credentials.json."""
import json
import os
import sys
import time
from pathlib import Path
_DEFAULT_CREDENTIALS_PATH = "~/.claude/.credentials.json"
def load_claude_oauth_token(
credentials_path: str = "",
refresh_if_expired: bool = True,
) -> str:
"""Return the Claude Code OAuth access token.
Reads ``~/.claude/.credentials.json`` (or the path you supply),
extracts ``claudeAiOauth.accessToken`` and checks expiry.
If ``refresh_if_expired`` is True and the token appears expired a
best-effort refresh via the Anthropic OAuth token endpoint is attempted
using the stored ``refreshToken``. The refresh may silently fail — in
that case a warning is written to *stderr* and the (possibly expired)
access token is returned anyway. The ``claude`` CLI normally refreshes
the token in the background; if you keep Claude Code open the token
will usually already be fresh.
Args:
credentials_path: Absolute or ``~``-relative path to the credentials
file. Defaults to ``~/.claude/.credentials.json``.
refresh_if_expired: Attempt a token refresh when the stored
``expiresAt`` (milliseconds epoch) is in the past. Set to
False to skip the network call entirely.
Returns:
The raw access token string (``sk-ant-oat-...``). Never printed to
stdout.
Raises:
FileNotFoundError: If the credentials file does not exist.
KeyError: If the expected keys are missing from the JSON.
json.JSONDecodeError: If the file is not valid JSON.
"""
path = Path(credentials_path or _DEFAULT_CREDENTIALS_PATH).expanduser()
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
oauth = data["claudeAiOauth"]
access_token: str = oauth["accessToken"]
expires_at_ms: int = oauth.get("expiresAt", 0)
refresh_token: str = oauth.get("refreshToken", "")
now_ms = int(time.time() * 1000)
is_expired = expires_at_ms > 0 and expires_at_ms <= now_ms
if is_expired and refresh_if_expired and refresh_token:
new_token = _try_refresh(refresh_token)
if new_token:
return new_token
print(
"warning: Claude OAuth token may be expired; refresh failed. "
"Returning current token anyway.",
file=sys.stderr,
)
return access_token
def _try_refresh(refresh_token: str) -> str:
"""Attempt to refresh the OAuth token. Returns empty string on failure.
The Anthropic OAuth refresh endpoint is not publicly documented.
This is a best-effort attempt using standard OAuth 2.0 conventions
(grant_type=refresh_token). It may not work if Anthropic changes
their token endpoint or requires additional client credentials.
"""
try:
import httpx # type: ignore
resp = httpx.post(
"https://auth.anthropic.com/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
headers={"content-type": "application/x-www-form-urlencoded"},
timeout=10.0,
)
if resp.status_code == 200:
body = resp.json()
new_token: str = body.get("access_token", "")
if new_token:
return new_token
except Exception as exc: # noqa: BLE001
print(f"warning: token refresh attempt failed: {exc}", file=sys.stderr)
return ""
@@ -0,0 +1,85 @@
"""Tests para load_claude_oauth_token."""
import json
import sys
import os
import time
sys.path.insert(0, os.path.dirname(__file__))
from load_claude_oauth_token import load_claude_oauth_token
def _write_creds(path, access_token, expires_at_ms, refresh_token="sk-ant-refresh-fake"):
data = {
"claudeAiOauth": {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": expires_at_ms,
}
}
with open(path, "w") as f:
json.dump(data, f)
def test_extrae_accessToken_de_un_credentials_fixture(tmp_path):
creds = tmp_path / "credentials.json"
_write_creds(str(creds), "sk-ant-oat-TEST123", expires_at_ms=int(time.time() * 1000) + 3_600_000)
token = load_claude_oauth_token(credentials_path=str(creds), refresh_if_expired=False)
assert token == "sk-ant-oat-TEST123"
def test_token_no_expirado_no_intenta_refresh(tmp_path, monkeypatch):
creds = tmp_path / "credentials.json"
# expiresAt 1 hora en el futuro
future_ms = int(time.time() * 1000) + 3_600_000
_write_creds(str(creds), "sk-ant-oat-FRESH", expires_at_ms=future_ms)
refresh_called = {"n": 0}
import load_claude_oauth_token as mod
original_try_refresh = mod._try_refresh
def fake_refresh(rt):
refresh_called["n"] += 1
return ""
monkeypatch.setattr(mod, "_try_refresh", fake_refresh)
token = load_claude_oauth_token(credentials_path=str(creds), refresh_if_expired=True)
assert token == "sk-ant-oat-FRESH"
assert refresh_called["n"] == 0
def test_token_expirado_con_refresh_if_expired_False_devuelve_token_igual(tmp_path):
creds = tmp_path / "credentials.json"
# expiresAt en el pasado
past_ms = int(time.time() * 1000) - 3_600_000
_write_creds(str(creds), "sk-ant-oat-EXPIRED", expires_at_ms=past_ms)
token = load_claude_oauth_token(credentials_path=str(creds), refresh_if_expired=False)
assert token == "sk-ant-oat-EXPIRED"
def test_token_expirado_refresh_falla_devuelve_token_y_warning_a_stderr(tmp_path, monkeypatch, capsys):
creds = tmp_path / "credentials.json"
past_ms = int(time.time() * 1000) - 3_600_000
_write_creds(str(creds), "sk-ant-oat-OLD", expires_at_ms=past_ms, refresh_token="sk-ant-refresh-old")
import load_claude_oauth_token as mod
def fake_refresh_fails(rt):
return "" # simula fallo silencioso
monkeypatch.setattr(mod, "_try_refresh", fake_refresh_fails)
token = load_claude_oauth_token(credentials_path=str(creds), refresh_if_expired=True)
assert token == "sk-ant-oat-OLD"
captured = capsys.readouterr()
assert "warning" in captured.err.lower()
assert "expired" in captured.err.lower() or "refresh" in captured.err.lower()
@@ -0,0 +1,112 @@
---
name: run_claude_tool_loop
kind: function
lang: py
domain: core
version: "1.0.0"
purity: impure
signature: "def run_claude_tool_loop(messages: list, tools: list, dispatch: dict, model: str = \"claude-opus-4-8\", system: str = \"\", max_tokens: int = 4096, max_iters: int = 8, on_text=None) -> dict"
description: "Bucle agentico de tool-use sobre la API de Anthropic. Llama stream_anthropic_messages en loop: si el modelo responde con tool_use, despacha cada tool via dispatch{name: callable}, anade el tool_result, y continua. Termina cuando stop_reason != tool_use o se alcanza max_iters. Devuelve {messages, final_text, stop_reason, iterations}."
tags: [claude-direct, anthropic, tools, agent, llm, tool-loop, streaming]
uses_functions: [stream_anthropic_messages_py_core, load_claude_oauth_token_py_core]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, typing.Callable]
params:
- name: messages
desc: "conversacion en formato Anthropic [{role, content}]. Se modifica in-place anadiendo mensajes assistant y tool_result"
- name: tools
desc: "lista de tool definitions Anthropic [{name, description, input_schema}]"
- name: dispatch
desc: "mapa {tool_name: callable(input_dict) -> result}. El callable puede retornar cualquier valor serializable o lanzar excepcion"
- name: model
desc: "ID del modelo Anthropic. Default: claude-opus-4-8"
- name: system
desc: "system prompt opcional"
- name: max_tokens
desc: "maximo de tokens por llamada API. Default 4096"
- name: max_iters
desc: "maximo de iteraciones del loop (proteccion contra loops infinitos). Default 8"
- name: on_text
desc: "callback opcional llamado con cada text delta: on_text(delta: str). Util para streaming en tiempo real"
output: "dict con keys: messages (lista actualizada), final_text (texto del ultimo turno assistant), stop_reason (end_turn|tool_use|max_tokens|max_iters|error), iterations (int)"
tested: true
tests:
- "tool_use seguido de end_turn ejecuta dispatch y termina"
- "tool dispatch que falla devuelve is_error True en tool_result"
- "tool desconocido devuelve is_error True"
- "on_text callback recibe deltas de texto"
- "max_iters limita el numero de iteraciones"
test_file_path: "python/functions/core/run_claude_tool_loop_test.py"
file_path: "python/functions/core/run_claude_tool_loop.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions/core")
from run_claude_tool_loop import run_claude_tool_loop
# Definir una tool propia
tools = [
{
"name": "add",
"description": "Suma dos numeros enteros y devuelve el resultado.",
"input_schema": {
"type": "object",
"properties": {
"a": {"type": "number", "description": "primer sumando"},
"b": {"type": "number", "description": "segundo sumando"},
},
"required": ["a", "b"],
},
}
]
# Implementacion de la tool (la provee el usuario)
dispatch = {
"add": lambda inp: inp["a"] + inp["b"],
}
messages = [{"role": "user", "content": "cuanto es 17 + 25?"}]
result = run_claude_tool_loop(
messages=messages,
tools=tools,
dispatch=dispatch,
model="claude-haiku-4-5-20251001",
on_text=lambda d: print(d, end="", flush=True),
)
print(f"\n[stop={result['stop_reason']} iters={result['iterations']}]")
# Claude llama a add(17, 25) -> 42, luego responde "El resultado es 42."
```
## Cuando usarla
Cuando quieras que Claude ejecute herramientas Python propias en un bucle
agentico: calculos, consultas a BD, llamadas a APIs externas, lectura de
archivos, etc. El loop gestiona automaticamente la negociacion
tool_use/tool_result con la API. Usa `on_text` para mostrar la respuesta
en tiempo real mientras el modelo piensa entre tool calls.
## Gotchas
- **messages se modifica in-place**: los mensajes assistant y tool_result se
anaden a la lista pasada. Pasa una copia si necesitas preservar el estado
original: `run_claude_tool_loop(list(messages), ...)`.
- **dispatch debe cubrir todos los tools declarados**: si el modelo invoca
una tool que no esta en dispatch, el tool_result contendra `is_error: True`.
Declara exactamente los tools que dispatch puede manejar.
- **Excepciones en dispatch son capturadas**: si tu callable lanza una
excepcion, se convierte en tool_result con `is_error: True` y el mensaje
del error. El loop continua — el modelo puede corregir y reintentar.
- **max_iters como seguro**: sin este limite, un modelo que siempre pide
tools podria iterar indefinidamente. Default 8 es conservador; subelo
para agentes complejos con muchos pasos.
- **final_text es solo el ultimo turno**: si el modelo genero texto en
iteraciones anteriores (raro pero posible), solo se devuelve el del
ultimo turno. El historial completo esta en `messages`.
@@ -0,0 +1,192 @@
"""Agentic tool-use loop over the Anthropic Messages API."""
import json
import sys
import os
from typing import Callable
sys.path.insert(0, os.path.dirname(__file__))
from stream_anthropic_messages import stream_anthropic_messages # noqa: E402
def run_claude_tool_loop(
messages: list,
tools: list,
dispatch: dict,
model: str = "claude-opus-4-8",
system: str = "",
max_tokens: int = 4096,
max_iters: int = 8,
on_text: Callable[[str], None] = None,
) -> dict:
"""Run an agentic tool-use loop against the Anthropic Messages API.
Repeatedly calls ``stream_anthropic_messages`` until the model stops
requesting tools (``stop_reason != "tool_use"``) or ``max_iters`` is
reached. At each iteration:
1. Stream the assistant response, accumulating text and tool_use blocks.
2. Append the assistant message (text + tool_use content blocks) to
``messages``.
3. If ``stop_reason == "tool_use"``: dispatch every requested tool via
the ``dispatch`` mapping, append a ``user`` message with all
``tool_result`` blocks, and continue.
4. Otherwise terminate and return the result dict.
Args:
messages: Conversation so far in Anthropic format. Modified in-place
(tool_result messages are appended). Pass a copy if you want to
preserve the original.
tools: List of tool definitions in Anthropic format::
[{"name": "add", "description": "...",
"input_schema": {"type": "object",
"properties": {"a": {"type": "number"},
"b": {"type": "number"}},
"required": ["a", "b"]}}]
dispatch: Mapping from tool name to callable ``(input_dict) -> result``.
The callable may return any JSON-serialisable value or raise an
exception (which is caught and returned as an error tool_result).
model: Anthropic model ID. Default ``claude-opus-4-8``.
system: Optional system prompt.
max_tokens: Max tokens per API call. Default 4096.
max_iters: Hard cap on tool-use iterations. Default 8.
on_text: Optional callback called with each text delta as it streams.
Useful for real-time display. E.g. ``on_text=lambda d: print(d, end="", flush=True)``.
Returns:
Dict with keys:
- ``messages``: updated conversation (same list, modified in-place).
- ``final_text``: concatenated text from the last assistant turn.
- ``stop_reason``: ``"end_turn"``, ``"tool_use"``, ``"max_tokens"``,
``"max_iters"``, or ``"error"``.
- ``iterations``: number of loop iterations executed.
"""
iterations = 0
final_text = ""
stop_reason = "max_iters"
for _ in range(max_iters):
iterations += 1
text_parts: list[str] = []
# tool_uses: list of {id, name, input_json_parts, index}
tool_uses: list[dict] = []
# index -> tool_use dict (for partial_json accumulation)
index_map: dict[int, dict] = {}
current_stop_reason = "end_turn"
for event in stream_anthropic_messages(
messages=messages,
model=model,
system=system,
tools=tools,
max_tokens=max_tokens,
):
etype = event.get("type", "")
if etype == "text":
delta = event["text"]
text_parts.append(delta)
if on_text is not None:
on_text(delta)
elif etype == "tool_use_start":
entry = {
"id": event["id"],
"name": event["name"],
"index": event["index"],
"partial_json_parts": [],
}
tool_uses.append(entry)
index_map[event["index"]] = entry
elif etype == "tool_input_delta":
idx = event["index"]
if idx in index_map:
index_map[idx]["partial_json_parts"].append(event["partial_json"])
elif etype == "done":
current_stop_reason = event.get("stop_reason", "end_turn")
elif etype == "error":
final_text = "".join(text_parts)
return {
"messages": messages,
"final_text": final_text,
"stop_reason": "error",
"iterations": iterations,
"error": event.get("message", "unknown error"),
}
final_text = "".join(text_parts)
stop_reason = current_stop_reason
# Build the assistant content blocks
assistant_content: list[dict] = []
if final_text:
assistant_content.append({"type": "text", "text": final_text})
for tu in tool_uses:
raw_input = "".join(tu["partial_json_parts"])
try:
parsed_input = json.loads(raw_input) if raw_input else {}
except json.JSONDecodeError:
parsed_input = {"_raw": raw_input}
assistant_content.append({
"type": "tool_use",
"id": tu["id"],
"name": tu["name"],
"input": parsed_input,
})
messages.append({"role": "assistant", "content": assistant_content})
if stop_reason != "tool_use" or not tool_uses:
break
# Dispatch tools and build tool_result message
tool_results: list[dict] = []
for tu in tool_uses:
tool_name = tu["name"]
raw_input = "".join(tu["partial_json_parts"])
try:
parsed_input = json.loads(raw_input) if raw_input else {}
except json.JSONDecodeError:
parsed_input = {"_raw": raw_input}
if tool_name not in dispatch:
result_content = f"Error: tool '{tool_name}' not found in dispatch"
is_error = True
else:
try:
result_value = dispatch[tool_name](parsed_input)
result_content = (
result_value
if isinstance(result_value, str)
else json.dumps(result_value)
)
is_error = False
except Exception as exc: # noqa: BLE001
result_content = f"Error executing {tool_name}: {exc}"
is_error = True
tool_result: dict = {
"type": "tool_result",
"tool_use_id": tu["id"],
"content": result_content,
}
if is_error:
tool_result["is_error"] = True
tool_results.append(tool_result)
messages.append({"role": "user", "content": tool_results})
else:
stop_reason = "max_iters"
return {
"messages": messages,
"final_text": final_text,
"stop_reason": stop_reason,
"iterations": iterations,
}
@@ -0,0 +1,188 @@
"""Tests para run_claude_tool_loop — usa fake de stream_anthropic_messages, sin HTTP."""
import sys
import os
import json
sys.path.insert(0, os.path.dirname(__file__))
# ---------------------------------------------------------------------------
# Fake de stream_anthropic_messages inyectado via monkeypatch
# ---------------------------------------------------------------------------
def _make_text_response(text: str, stop_reason: str = "end_turn"):
"""Genera la secuencia de eventos de una respuesta de texto pura."""
for ch in text:
yield {"type": "text", "text": ch}
yield {"type": "done", "stop_reason": stop_reason}
def _make_tool_use_response(tool_id: str, tool_name: str, tool_input: dict):
"""Genera la secuencia de eventos de un tool_use."""
yield {"type": "tool_use_start", "id": tool_id, "name": tool_name, "index": 0}
raw = json.dumps(tool_input)
# Simula streaming del JSON en dos partes
mid = len(raw) // 2
yield {"type": "tool_input_delta", "index": 0, "partial_json": raw[:mid]}
yield {"type": "tool_input_delta", "index": 0, "partial_json": raw[mid:]}
yield {"type": "done", "stop_reason": "tool_use"}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_tool_use_seguido_de_end_turn_ejecuta_dispatch_y_termina(monkeypatch):
import run_claude_tool_loop as mod
call_count = {"n": 0}
def fake_stream(messages, model, system, tools, max_tokens):
call_count["n"] += 1
if call_count["n"] == 1:
# Primera llamada: el modelo pide la tool "add"
yield from _make_tool_use_response("toolu_01", "add", {"a": 3, "b": 4})
else:
# Segunda llamada: el modelo responde con el resultado
yield from _make_text_response("El resultado es 7.")
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream)
dispatch_called = {"input": None}
def add_tool(inp):
dispatch_called["input"] = inp
return inp["a"] + inp["b"]
messages = [{"role": "user", "content": "cuanto es 3 + 4?"}]
result = mod.run_claude_tool_loop(
messages=messages,
tools=[{"name": "add", "description": "suma", "input_schema": {}}],
dispatch={"add": add_tool},
model="claude-haiku-4-5-20251001",
)
assert result["stop_reason"] == "end_turn"
assert result["iterations"] == 2
assert result["final_text"] == "El resultado es 7."
assert dispatch_called["input"] == {"a": 3, "b": 4}
# Verifica que se anado el tool_result al historial
roles = [m["role"] for m in result["messages"]]
assert roles.count("assistant") == 2
assert roles.count("user") >= 2
# El mensaje user con tool_result debe tener is_error=False (ausente)
tool_result_msg = next(
m for m in result["messages"]
if m["role"] == "user" and isinstance(m["content"], list)
and m["content"] and m["content"][0].get("type") == "tool_result"
)
assert tool_result_msg["content"][0].get("is_error") is None or \
tool_result_msg["content"][0].get("is_error") is False
def test_tool_dispatch_que_falla_devuelve_is_error_True_en_tool_result(monkeypatch):
import run_claude_tool_loop as mod
call_count = {"n": 0}
def fake_stream(messages, model, system, tools, max_tokens):
call_count["n"] += 1
if call_count["n"] == 1:
yield from _make_tool_use_response("toolu_02", "divide", {"a": 10, "b": 0})
else:
yield from _make_text_response("No se puede dividir por cero.")
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream)
def divide_tool(inp):
return inp["a"] / inp["b"] # ZeroDivisionError
messages = [{"role": "user", "content": "divide 10 entre 0"}]
result = mod.run_claude_tool_loop(
messages=messages,
tools=[{"name": "divide", "description": "division", "input_schema": {}}],
dispatch={"divide": divide_tool},
)
# Debe haber un tool_result con is_error=True
tool_result_msg = next(
m for m in result["messages"]
if m["role"] == "user" and isinstance(m["content"], list)
and m["content"] and m["content"][0].get("type") == "tool_result"
)
assert tool_result_msg["content"][0].get("is_error") is True
assert "Error" in tool_result_msg["content"][0]["content"]
def test_tool_desconocido_devuelve_is_error_True(monkeypatch):
import run_claude_tool_loop as mod
call_count = {"n": 0}
def fake_stream(messages, model, system, tools, max_tokens):
call_count["n"] += 1
if call_count["n"] == 1:
yield from _make_tool_use_response("toolu_03", "nonexistent_tool", {"x": 1})
else:
yield from _make_text_response("No pude usar esa tool.")
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream)
messages = [{"role": "user", "content": "usa nonexistent_tool"}]
result = mod.run_claude_tool_loop(
messages=messages,
tools=[],
dispatch={}, # dispatch vacio — tool no existe
)
tool_result_msg = next(
m for m in result["messages"]
if m["role"] == "user" and isinstance(m["content"], list)
and m["content"] and m["content"][0].get("type") == "tool_result"
)
assert tool_result_msg["content"][0].get("is_error") is True
assert "not found" in tool_result_msg["content"][0]["content"]
def test_on_text_callback_recibe_deltas_de_texto(monkeypatch):
import run_claude_tool_loop as mod
def fake_stream(messages, model, system, tools, max_tokens):
yield from _make_text_response("hola mundo")
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream)
received = []
messages = [{"role": "user", "content": "saluda"}]
result = mod.run_claude_tool_loop(
messages=messages,
tools=[],
dispatch={},
on_text=lambda d: received.append(d),
)
assert "".join(received) == "hola mundo"
assert result["final_text"] == "hola mundo"
def test_max_iters_limita_el_numero_de_iteraciones(monkeypatch):
import run_claude_tool_loop as mod
# Siempre responde con tool_use para forzar loop infinito
def fake_stream_infinite(messages, model, system, tools, max_tokens):
yield from _make_tool_use_response("toolu_inf", "noop", {})
monkeypatch.setattr(mod, "stream_anthropic_messages", fake_stream_infinite)
messages = [{"role": "user", "content": "loop"}]
result = mod.run_claude_tool_loop(
messages=messages,
tools=[{"name": "noop", "description": "no-op", "input_schema": {}}],
dispatch={"noop": lambda inp: "ok"},
max_iters=3,
)
assert result["iterations"] == 3
assert result["stop_reason"] == "max_iters"
@@ -0,0 +1,86 @@
---
name: stream_anthropic_messages
kind: function
lang: py
domain: core
version: "1.0.0"
purity: impure
signature: "def stream_anthropic_messages(messages: list, model: str = \"claude-opus-4-8\", system: str = \"\", tools: list = None, max_tokens: int = 4096, token: str = \"\") -> Iterator[dict]"
description: "Llama a https://api.anthropic.com/v1/messages con stream:true usando el token OAuth de Claude Code y hace yield de eventos normalizados: text, tool_use_start, tool_input_delta, done, error. Parsea el SSE en tiempo real. Si token vacio llama load_claude_oauth_token automaticamente."
tags: [claude-direct, anthropic, streaming, sse, llm, tools, http]
uses_functions: [load_claude_oauth_token_py_core]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, sys, typing.Iterator, httpx]
params:
- name: messages
desc: "lista de mensajes Anthropic [{role: user|assistant, content: ...}]"
- name: model
desc: "ID del modelo Anthropic. Default: claude-opus-4-8"
- name: system
desc: "system prompt opcional. Si vacio no se incluye en el body"
- name: tools
desc: "lista de tool definitions [{name, description, input_schema}]. None para no usar tools"
- name: max_tokens
desc: "maximo de tokens en la respuesta. Default 4096"
- name: token
desc: "access token OAuth. Si vacio se carga automaticamente desde ~/.claude/.credentials.json"
output: "Iterator[dict] — eventos normalizados: {type:text,text}, {type:tool_use_start,id,name,index}, {type:tool_input_delta,index,partial_json}, {type:done,stop_reason}, {type:error,message}"
tested: true
tests:
- "parse_sse_chunk text delta emite evento text"
- "parse_sse_chunk tool_use_start emite evento tool_use_start con id y name"
- "parse_sse_chunk tool_input_delta emite evento tool_input_delta con partial_json"
- "parse_sse_chunk message_delta con stop_reason emite done"
- "parse_sse_chunk secuencia completa text PONG reconstruye el texto"
- "parse_sse_chunk secuencia completa tool_use reconstruye el input JSON"
test_file_path: "python/functions/core/stream_anthropic_messages_test.py"
file_path: "python/functions/core/stream_anthropic_messages.py"
---
## Ejemplo
```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_reason={event['stop_reason']}]")
elif event["type"] == "error":
print(f"ERROR: {event['message']}", file=sys.stderr)
print("respuesta completa:", text)
```
## Cuando usarla
Cuando necesites hablar directamente con la API de Anthropic desde Python
sin lanzar la CLI `claude`, especialmente cuando quieras streaming en tiempo
real del texto o necesites ejecutar tools propias via `run_claude_tool_loop`.
Tambien util para integrar Claude en scripts de analisis donde el bucle
de tools se gestiona en Python.
## Gotchas
- **El parser SSE es puro y testeable**: `parse_sse_chunk` no hace HTTP.
Puedes testear el parseo completo sin mocks de red.
- **Eventos `raw` son pass-through**: `message_start`, `content_block_stop`,
`ping` y otros eventos no mapeados se emiten como `{type: raw, event: ..., data: ...}`.
El consumidor los puede ignorar o inspeccionar.
- **Reconstruccion de tool input**: los `partial_json` de un mismo `index`
deben concatenarse hasta el evento `done`. `run_claude_tool_loop` hace esto
automaticamente.
- **Timeout 120s**: configurable en el codigo si necesitas respuestas muy largas.
- La funcion `parse_sse_chunk` es pure (importable y testeable por separado).
@@ -0,0 +1,195 @@
"""Stream responses from the Anthropic Messages API using a Claude Code OAuth token."""
import json
import sys
from typing import Iterator
_API_URL = "https://api.anthropic.com/v1/messages"
_ANTHROPIC_VERSION = "2023-06-01"
# ---------------------------------------------------------------------------
# Pure SSE parser — fully testable without HTTP
# ---------------------------------------------------------------------------
def parse_sse_chunk(chunk: str) -> Iterator[dict]:
"""Parse one SSE chunk (possibly containing multiple event/data pairs).
Yields normalised event dicts:
- ``{"type": "text", "text": "<delta>"}``
- ``{"type": "tool_use_start", "id": "...", "name": "...", "index": N}``
- ``{"type": "tool_input_delta","index": N, "partial_json": "<delta>"}``
- ``{"type": "done", "stop_reason": "<...>"}``
- ``{"type": "raw", "event": "...", "data": {...}}`` (pass-through)
Args:
chunk: Raw SSE text with ``event:`` / ``data:`` lines, separated by
blank lines between events.
"""
current_event = ""
current_data = ""
for line in chunk.splitlines():
if line.startswith("event:"):
current_event = line[len("event:"):].strip()
elif line.startswith("data:"):
current_data = line[len("data:"):].strip()
elif line == "":
if current_data and current_data != "[DONE]":
try:
data = json.loads(current_data)
except json.JSONDecodeError:
current_event = ""
current_data = ""
continue
yield from _normalise_event(current_event, data)
current_event = ""
current_data = ""
# flush final event without trailing blank line
if current_data and current_data != "[DONE]":
try:
data = json.loads(current_data)
yield from _normalise_event(current_event, data)
except json.JSONDecodeError:
pass
def _normalise_event(event_type: str, data: dict) -> Iterator[dict]:
"""Convert a raw SSE event dict into one or more normalised dicts."""
if event_type == "content_block_start":
block = data.get("content_block", {})
if block.get("type") == "tool_use":
yield {
"type": "tool_use_start",
"id": block.get("id", ""),
"name": block.get("name", ""),
"index": data.get("index", 0),
}
elif event_type == "content_block_delta":
delta = data.get("delta", {})
delta_type = delta.get("type", "")
if delta_type == "text_delta":
yield {"type": "text", "text": delta.get("text", "")}
elif delta_type == "input_json_delta":
yield {
"type": "tool_input_delta",
"index": data.get("index", 0),
"partial_json": delta.get("partial_json", ""),
}
elif event_type == "message_delta":
stop_reason = data.get("delta", {}).get("stop_reason", "")
if stop_reason:
yield {"type": "done", "stop_reason": stop_reason}
elif event_type == "message_stop":
# emitted after message_delta — only yield done if not already seen
pass
else:
# pass-through for message_start, content_block_stop, ping, etc.
yield {"type": "raw", "event": event_type, "data": data}
# ---------------------------------------------------------------------------
# Streaming HTTP function
# ---------------------------------------------------------------------------
def stream_anthropic_messages(
messages: list,
model: str = "claude-opus-4-8",
system: str = "",
tools: list = None,
max_tokens: int = 4096,
token: str = "",
) -> Iterator[dict]:
"""Stream an Anthropic Messages API call using a Claude Code OAuth token.
Posts to ``https://api.anthropic.com/v1/messages`` with ``stream: true``
and yields normalised event dicts as they arrive:
- ``{"type": "text", "text": "<delta>"}``
- ``{"type": "tool_use_start", "id": "...", "name": "...", "index": N}``
- ``{"type": "tool_input_delta","index": N, "partial_json": "<delta>"}``
- ``{"type": "done", "stop_reason": "end_turn|tool_use|..."}``
- ``{"type": "error", "message": "..."}`` on HTTP error / exception
Args:
messages: List of message dicts in Anthropic format
(``[{"role": "user", "content": "..."}]``).
model: Anthropic model ID. Default ``claude-opus-4-8``.
system: Optional system prompt string.
tools: Optional list of tool definition dicts.
max_tokens: Maximum tokens in the response. Default 4096.
token: OAuth access token. If empty, ``load_claude_oauth_token()``
is called automatically.
Yields:
Normalised event dicts (see above).
"""
if not token:
try:
try:
from core.load_claude_oauth_token import load_claude_oauth_token
except ImportError:
from load_claude_oauth_token import load_claude_oauth_token
token = load_claude_oauth_token()
except Exception as exc:
yield {"type": "error", "message": f"failed to load token: {exc}"}
return
body: dict = {
"model": model,
"max_tokens": max_tokens,
"messages": messages,
"stream": True,
}
if system:
body["system"] = system
if tools:
body["tools"] = tools
headers = {
"authorization": f"Bearer {token}",
"anthropic-version": _ANTHROPIC_VERSION,
"content-type": "application/json",
}
try:
import httpx
with httpx.stream(
"POST",
_API_URL,
json=body,
headers=headers,
timeout=120.0,
) as resp:
if resp.status_code != 200:
error_body = resp.read().decode("utf-8", errors="replace")
yield {
"type": "error",
"message": f"HTTP {resp.status_code}: {error_body[:500]}",
}
return
buffer = ""
for chunk in resp.iter_text():
buffer += chunk
# Process complete SSE blocks (separated by double newlines)
while "\n\n" in buffer:
block, buffer = buffer.split("\n\n", 1)
block += "\n\n"
yield from parse_sse_chunk(block)
# Flush any remaining content
if buffer.strip():
yield from parse_sse_chunk(buffer + "\n\n")
except Exception as exc:
yield {"type": "error", "message": str(exc)}
@@ -0,0 +1,163 @@
"""Tests para stream_anthropic_messages — solo el parser SSE puro, sin HTTP."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from stream_anthropic_messages import parse_sse_chunk
# ---------------------------------------------------------------------------
# Helpers: construir chunks SSE sinteticos (igual a lo que envia Anthropic)
# ---------------------------------------------------------------------------
def sse(event: str, data: dict) -> str:
import json
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
# ---------------------------------------------------------------------------
# Tests de eventos individuales
# ---------------------------------------------------------------------------
def test_parse_sse_chunk_text_delta_emite_evento_text():
chunk = sse("content_block_delta", {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "P"},
})
events = list(parse_sse_chunk(chunk))
text_events = [e for e in events if e["type"] == "text"]
assert len(text_events) == 1
assert text_events[0]["text"] == "P"
def test_parse_sse_chunk_tool_use_start_emite_evento_tool_use_start():
chunk = sse("content_block_start", {
"type": "content_block_start",
"index": 1,
"content_block": {"type": "tool_use", "id": "toolu_01", "name": "add"},
})
events = list(parse_sse_chunk(chunk))
tu_events = [e for e in events if e["type"] == "tool_use_start"]
assert len(tu_events) == 1
assert tu_events[0]["id"] == "toolu_01"
assert tu_events[0]["name"] == "add"
assert tu_events[0]["index"] == 1
def test_parse_sse_chunk_tool_input_delta_emite_evento_tool_input_delta():
chunk = sse("content_block_delta", {
"type": "content_block_delta",
"index": 1,
"delta": {"type": "input_json_delta", "partial_json": '{"a":'},
})
events = list(parse_sse_chunk(chunk))
ti_events = [e for e in events if e["type"] == "tool_input_delta"]
assert len(ti_events) == 1
assert ti_events[0]["index"] == 1
assert ti_events[0]["partial_json"] == '{"a":'
def test_parse_sse_chunk_message_delta_con_stop_reason_emite_done():
chunk = sse("message_delta", {
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 5},
})
events = list(parse_sse_chunk(chunk))
done_events = [e for e in events if e["type"] == "done"]
assert len(done_events) == 1
assert done_events[0]["stop_reason"] == "end_turn"
def test_parse_sse_chunk_secuencia_completa_text_PONG_reconstruye_el_texto():
"""Simula la secuencia completa de eventos SSE para una respuesta de texto."""
import json
# Secuencia tipica: message_start, content_block_start text, deltas, stop
chunks = "".join([
sse("message_start", {
"type": "message_start",
"message": {"id": "msg_01", "type": "message", "role": "assistant",
"content": [], "model": "claude-haiku-4-5-20251001",
"stop_reason": None, "usage": {"input_tokens": 5}},
}),
sse("content_block_start", {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
}),
sse("content_block_delta", {
"type": "content_block_delta", "index": 0,
"delta": {"type": "text_delta", "text": "P"},
}),
sse("content_block_delta", {
"type": "content_block_delta", "index": 0,
"delta": {"type": "text_delta", "text": "ON"},
}),
sse("content_block_delta", {
"type": "content_block_delta", "index": 0,
"delta": {"type": "text_delta", "text": "G"},
}),
sse("content_block_stop", {"type": "content_block_stop", "index": 0}),
sse("message_delta", {
"type": "message_delta",
"delta": {"stop_reason": "end_turn"},
"usage": {"output_tokens": 4},
}),
sse("message_stop", {"type": "message_stop"}),
])
events = list(parse_sse_chunk(chunks))
text = "".join(e["text"] for e in events if e["type"] == "text")
done = [e for e in events if e["type"] == "done"]
assert text == "PONG"
assert len(done) == 1
assert done[0]["stop_reason"] == "end_turn"
def test_parse_sse_chunk_secuencia_completa_tool_use_reconstruye_el_input_JSON():
"""Simula la secuencia SSE para un tool_use con input_json_delta streaming."""
chunks = "".join([
sse("content_block_start", {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "tool_use", "id": "toolu_42", "name": "add"},
}),
sse("content_block_delta", {
"type": "content_block_delta", "index": 0,
"delta": {"type": "input_json_delta", "partial_json": '{"a":'},
}),
sse("content_block_delta", {
"type": "content_block_delta", "index": 0,
"delta": {"type": "input_json_delta", "partial_json": ' 3, "b": 4}'},
}),
sse("content_block_stop", {"type": "content_block_stop", "index": 0}),
sse("message_delta", {
"type": "message_delta",
"delta": {"stop_reason": "tool_use"},
"usage": {"output_tokens": 10},
}),
])
events = list(parse_sse_chunk(chunks))
tu_start = [e for e in events if e["type"] == "tool_use_start"]
ti_deltas = [e for e in events if e["type"] == "tool_input_delta"]
done = [e for e in events if e["type"] == "done"]
assert len(tu_start) == 1
assert tu_start[0]["name"] == "add"
assert tu_start[0]["id"] == "toolu_42"
# Reconstruir el input JSON concatenando los partial_json
import json
raw_input = "".join(d["partial_json"] for d in ti_deltas)
parsed = json.loads(raw_input)
assert parsed == {"a": 3, "b": 4}
assert len(done) == 1
assert done[0]["stop_reason"] == "tool_use"