feat: externalize apps/analysis to Gitea repos, add analysis table

- Migration 007: repo_url on apps table + analysis table with FTS5
- Analysis struct, parser, CRUD, validation, hash computation
- Selective purge: remote-only apps/analysis preserved across fn index
- CLI: fn app list/clone/pull, fn analysis list/clone/pull
- search/show/list now include analysis results
- Apps removed from git tracking (content lives in Gitea repos)
- .gitkeep for apps/ and analysis/ dirs
- Bash functions: jupyter analysis pipeline, shell utilities
- Browser domain: CDP functions moved from infra to browser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:23:51 +02:00
parent 722f29b71b
commit bf1efb2099
111 changed files with 2766 additions and 5043 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: init_uv_venv
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "init_uv_venv([project_dir: string]) -> string"
description: "Crea un virtualenv Python con uv en el directorio dado si no existe. Fallback a python3 -m venv. Retorna la ruta del venv."
tags: [python, venv, uv, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/init_uv_venv.sh"
---
## Ejemplo
```bash
source init_uv_venv.sh
venv=$(init_uv_venv /home/lucas/analysis/finanzas)
echo "Venv creado en: $venv"
# Idempotente — si ya existe, retorna la ruta sin recrear
venv=$(init_uv_venv .)
```
## Notas
Idempotente: si el venv ya existe con un python valido, retorna la ruta sin hacer nada. Prefiere uv por velocidad, usa python3 como fallback.
+35
View File
@@ -0,0 +1,35 @@
# init_uv_venv
# -------------
# Crea un venv con uv en el directorio especificado si no existe.
# Fallback a python -m venv si uv no esta disponible.
# Imprime la ruta del venv a stdout.
#
# USO (sourced):
# source init_uv_venv.sh
# venv_path=$(init_uv_venv /path/to/project)
init_uv_venv() {
local project_dir="${1:-.}"
local venv_path="${project_dir}/.venv"
if [ -d "$venv_path" ] && [ -f "$venv_path/bin/python" ]; then
echo "$venv_path"
return 0
fi
if command -v uv &>/dev/null; then
(cd "$project_dir" && uv venv) >/dev/null 2>&1
elif command -v python3 &>/dev/null; then
python3 -m venv "$venv_path"
else
echo "init_uv_venv: ni uv ni python3 disponibles" >&2
return 1
fi
if [ ! -f "$venv_path/bin/python" ]; then
echo "init_uv_venv: fallo al crear venv en $venv_path" >&2
return 1
fi
echo "$venv_path"
}
+35
View File
@@ -0,0 +1,35 @@
---
name: uv_add_packages
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "uv_add_packages(project_dir: string, ...packages: string) -> void"
description: "Instala paquetes Python en un proyecto usando uv add con fallback a pip. Inicializa pyproject.toml si no existe."
tags: [python, uv, pip, packages, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/uv_add_packages.sh"
---
## Ejemplo
```bash
source uv_add_packages.sh
uv_add_packages /home/lucas/analysis/finanzas jupyter jupyterlab pandas numpy
# Solo un paquete
uv_add_packages . polars
```
## Notas
Requiere que el venv ya exista (usa `init_uv_venv` antes). Prefiere uv por velocidad y reproducibilidad (lockfile). Si uv no esta disponible, usa pip del venv directamente.
+35
View File
@@ -0,0 +1,35 @@
# uv_add_packages
# -----------------
# Instala paquetes Python en un proyecto con uv add.
# Inicializa pyproject.toml si no existe.
# Fallback a pip install si uv no esta disponible.
#
# USO (sourced):
# source uv_add_packages.sh
# uv_add_packages /path/to/project jupyter jupyterlab pandas
uv_add_packages() {
local project_dir="$1"
shift
local packages=("$@")
if [ ${#packages[@]} -eq 0 ]; then
echo "uv_add_packages: se requiere al menos un paquete" >&2
return 1
fi
if [ ! -d "$project_dir/.venv" ]; then
echo "uv_add_packages: no existe .venv en $project_dir — ejecuta init_uv_venv primero" >&2
return 1
fi
if command -v uv &>/dev/null; then
# Inicializar pyproject.toml si no existe
if [ ! -f "$project_dir/pyproject.toml" ]; then
(cd "$project_dir" && uv init 2>/dev/null) || true
fi
(cd "$project_dir" && uv add "${packages[@]}" 2>&1)
else
"$project_dir/.venv/bin/pip" install "${packages[@]}" 2>&1
fi
}
@@ -0,0 +1,33 @@
---
name: write_claude_jupyter_rules
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_claude_jupyter_rules([project_dir: string]) -> string"
description: "Genera o actualiza .claude/CLAUDE.md con reglas para agentes que trabajan con Jupyter: celdas inmutables, programacion funcional, uso de MCP, acceso al fn_registry."
tags: [claude, jupyter, rules, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_claude_jupyter_rules.sh"
---
## Ejemplo
```bash
source write_claude_jupyter_rules.sh
path=$(write_claude_jupyter_rules /home/lucas/analysis/finanzas)
echo "Reglas escritas en: $path"
```
## Notas
Idempotente: si CLAUDE.md ya contiene las reglas (detecta "JUPYTER HABILITADO"), no las duplica. Si CLAUDE.md existe sin las reglas, las prepend al contenido existente. Incluye instrucciones de acceso al fn_registry via `FN_REGISTRY_ROOT`.
@@ -0,0 +1,74 @@
# write_claude_jupyter_rules
# ----------------------------
# Genera .claude/CLAUDE.md con reglas para agentes que trabajan con Jupyter.
# Si ya existe CLAUDE.md y no tiene las reglas, las prepend.
#
# USO (sourced):
# source write_claude_jupyter_rules.sh
# write_claude_jupyter_rules /path/to/project
write_claude_jupyter_rules() {
local project_dir="${1:-.}"
local claude_dir="${project_dir}/.claude"
local claude_md="${claude_dir}/CLAUDE.md"
mkdir -p "$claude_dir"
# Si ya tiene las reglas, no hacer nada
if [ -f "$claude_md" ] && grep -q "JUPYTER HABILITADO" "$claude_md" 2>/dev/null; then
echo "$claude_md"
return 0
fi
local rules
rules='# JUPYTER HABILITADO EN ESTE ANALISIS
## Reglas OBLIGATORIAS para Claude
### 1. CODIGO INMUTABLE — NUNCA MODIFICAR CELDAS EXISTENTES
- **PROHIBIDO** usar NotebookEdit para reemplazar celdas existentes
- **SIEMPRE** anadir celdas NUEVAS al final del notebook
- Si hay un error en una celda, crear celda nueva con la correccion
- El historial de trabajo debe quedar intacto para trazabilidad
### 2. PROGRAMACION FUNCIONAL OBLIGATORIA
- **Funciones puras**: sin efectos secundarios, mismo input -> mismo output
- **Inmutabilidad**: nunca mutar datos, crear copias transformadas
- **Composicion**: funciones pequenas que se combinan
- Preferir: `map`, `filter`, `reduce`, list comprehensions
- Evitar: loops con mutacion, `global`, modificar argumentos in-place
### 3. SIEMPRE usar MCP jupyter para ejecutar codigo Python
- Las ejecuciones se ven en tiempo real en Jupyter Lab del usuario
- Compartimos variables y estado del kernel
- **NUNCA usar bash para ejecutar Python en este analisis**
### 4. Verificar Jupyter activo ANTES de ejecutar
- Si no esta activo: pedir al usuario que ejecute `./run-jupyter-lab.sh`
### 5. Gestion de notebooks
- Notebooks en la carpeta `notebooks/` o subcarpetas
- Si un notebook tiene >50 celdas, crear uno nuevo
- Nombrar descriptivamente: `01_exploracion.ipynb`, `02_limpieza.ipynb`
### 6. Gestion de Python
- **SIEMPRE usar `uv`** para gestionar dependencias
- Anadir paquetes con `uv add nombre_paquete`
### 7. Acceso al fn_registry
- `FN_REGISTRY_ROOT` apunta a la raiz del registry
- Para importar funciones Python: `sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))`
- Para consultar registry.db: `sqlite3` o `import sqlite3` con la ruta `$FN_REGISTRY_ROOT/registry.db`
'
if [ -f "$claude_md" ]; then
local existing
existing=$(cat "$claude_md")
printf '%s\n%s' "$rules" "$existing" > "$claude_md"
else
echo "$rules" > "$claude_md"
fi
echo "$claude_md"
}
@@ -0,0 +1,41 @@
---
name: write_jupyter_launcher
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_jupyter_launcher([project_dir: string]) -> string"
description: "Genera un script run-jupyter-lab.sh que lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto y token deshabilitado."
tags: [jupyter, launcher, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_jupyter_launcher.sh"
---
## Ejemplo
```bash
source write_jupyter_launcher.sh
path=$(write_jupyter_launcher /home/lucas/analysis/finanzas)
echo "Launcher creado en: $path"
# Luego en otra terminal:
# ./run-jupyter-lab.sh [puerto]
```
## Notas
El launcher generado:
- Autodetecta un puerto libre (8888-8899)
- Guarda el puerto en `.jupyter-port` para que otros procesos lo lean
- Activa el venv automaticamente
- Lanza Jupyter Lab en modo `--collaborative` (requiere jupyter-collaboration)
- Token y password deshabilitados para acceso local
@@ -0,0 +1,65 @@
# write_jupyter_launcher
# -----------------------
# Genera un script run-jupyter-lab.sh en el directorio dado.
# El script lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto.
#
# USO (sourced):
# source write_jupyter_launcher.sh
# write_jupyter_launcher /path/to/project
write_jupyter_launcher() {
local project_dir="${1:-.}"
local launcher="${project_dir}/run-jupyter-lab.sh"
cat > "$launcher" << 'LAUNCHER'
#!/bin/bash
# Jupyter Lab — modo colaborativo con autodeteccion de puerto
# Generado por write_jupyter_launcher (fn_registry)
find_free_port() {
for port in 8888 8889 8890 8891 8892 8893 8894 8895 8896 8897 8898 8899; do
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
! lsof -i:"$port" >/dev/null 2>&1; then
echo $port
return
fi
done
echo 8888
}
PORT=${1:-$(find_free_port)}
cd "$(dirname "$0")"
echo $PORT > .jupyter-port
source .venv/bin/activate 2>/dev/null || true
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
echo "ERROR: jupyter-collaboration no esta instalado"
echo "Instala con: uv add jupyter-collaboration"
exit 1
fi
echo "════════════════════════════════════════════════"
echo " Jupyter Lab + Colaboracion en puerto $PORT"
echo "════════════════════════════════════════════════"
echo ""
echo " Abre: http://localhost:$PORT"
echo " Ctrl+C para detener"
echo ""
jupyter lab \
--port=$PORT \
--no-browser \
--ServerApp.token='' \
--ServerApp.password='' \
--ServerApp.disable_check_xsrf=True \
--ServerApp.allow_origin='*' \
--ServerApp.root_dir="$(pwd)" \
--YDocExtension.ystore_class='ypy_websocket.ystore.TempFileYStore' \
--collaborative
LAUNCHER
chmod +x "$launcher"
echo "$launcher"
}
@@ -0,0 +1,56 @@
---
name: write_jupyter_registry_kernel
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_jupyter_registry_kernel([project_dir: string]) -> string"
description: "Genera un script de startup de IPython que autoconfigura FN_REGISTRY_ROOT, sys.path a python/functions del registry, y helpers fn_query/fn_search/fn_code para consultar registry.db desde notebooks."
tags: [jupyter, ipython, kernel, registry, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_jupyter_registry_kernel.sh"
---
## Ejemplo
```bash
source write_jupyter_registry_kernel.sh
path=$(write_jupyter_registry_kernel /home/lucas/analysis/finanzas)
echo "Startup creado en: $path"
```
Luego en cualquier notebook del proyecto:
```python
# Ya disponible automaticamente al abrir el notebook:
# Buscar funciones
fn_search("finance")
# Consultar registry.db directamente
fn_query("SELECT id, signature FROM functions WHERE domain = ?", ("core",))
# Ver codigo de una funcion
print(fn_code("filter_list_py_core"))
# Importar funciones Python del registry directamente
from core import filter_list, map_list
from finance import sma, ema, rsi
```
## Notas
Genera `.ipython/profile_default/startup/00_fn_registry.py` que se ejecuta automaticamente al iniciar cualquier kernel IPython en el proyecto. No requiere imports manuales — las funciones `fn_query`, `fn_search` y `fn_code` estan disponibles inmediatamente en cada notebook.
El `sys.path` se configura para que cada dominio de `python/functions/` sea importable directamente (`from core import filter_list`).
Detecta `FN_REGISTRY_ROOT` buscando la raiz del repo git o subiendo directorios hasta encontrar `registry.db`.
@@ -0,0 +1,111 @@
# write_jupyter_registry_kernel
# -------------------------------
# Genera un script de startup de IPython que autoconfigura el acceso
# al fn_registry en cada notebook: FN_REGISTRY_ROOT, sys.path a
# python/functions, y un helper fn_query() para consultar registry.db.
#
# USO (sourced):
# source write_jupyter_registry_kernel.sh
# write_jupyter_registry_kernel /path/to/project
write_jupyter_registry_kernel() {
local project_dir="${1:-.}"
local startup_dir="${project_dir}/.ipython/profile_default/startup"
local registry_root
registry_root="$(cd "$project_dir" && cd "$(git -C "$project_dir" rev-parse --show-toplevel 2>/dev/null || echo "../..")" && pwd)"
# Fallback: si no es git, buscar registry.db subiendo directorios
if [ ! -f "$registry_root/registry.db" ] && [ -f "$project_dir/../../registry.db" ]; then
registry_root="$(cd "$project_dir/../.." && pwd)"
fi
mkdir -p "$startup_dir"
cat > "${startup_dir}/00_fn_registry.py" << STARTUP
"""
fn_registry kernel startup
Autoconfigura acceso al registry en cada notebook.
Generado por write_jupyter_registry_kernel (fn_registry).
"""
import os
import sys
import sqlite3
from pathlib import Path
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
FN_REGISTRY_ROOT = Path("${registry_root}")
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
# ── sys.path: importar funciones Python del registry ────────
_python_functions = FN_REGISTRY_ROOT / "python" / "functions"
for _domain in sorted(_python_functions.iterdir()) if _python_functions.exists() else []:
if _domain.is_dir() and not _domain.name.startswith("_"):
_path = str(_domain)
if _path not in sys.path:
sys.path.insert(0, _path)
# Tambien el directorio padre para imports por dominio: from core import filter_list
_pf = str(_python_functions)
if _pf not in sys.path:
sys.path.insert(0, _pf)
# ── fn_query: consultar registry.db desde el notebook ───────
_REGISTRY_DB = FN_REGISTRY_ROOT / "registry.db"
def fn_query(sql, params=()):
"""Ejecuta una consulta SQL sobre registry.db y retorna las filas.
Ejemplos:
fn_query("SELECT id, description FROM functions WHERE domain = ?", ("finance",))
fn_query("SELECT id FROM functions_fts WHERE functions_fts MATCH ?", ("slice*",))
"""
if not _REGISTRY_DB.exists():
raise FileNotFoundError(f"registry.db no encontrado en {_REGISTRY_DB}")
con = sqlite3.connect(str(_REGISTRY_DB))
con.row_factory = sqlite3.Row
try:
rows = con.execute(sql, params).fetchall()
return [dict(r) for r in rows]
finally:
con.close()
def fn_search(term):
"""Busca funciones y tipos en el registry por nombre o descripcion.
Ejemplo:
fn_search("slice")
fn_search("finance")
"""
fts_term = f"name:{term}* OR description:{term}*"
functions = fn_query(
"SELECT id, kind, purity, lang, description FROM functions "
"WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?) "
"ORDER BY name", (fts_term,)
)
types = fn_query(
"SELECT id, algebraic, lang, description FROM types "
"WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?) "
"ORDER BY name", (fts_term,)
)
return {"functions": functions, "types": types}
def fn_code(function_id):
"""Retorna el codigo fuente de una funcion del registry.
Ejemplo:
print(fn_code("filter_list_py_core"))
"""
rows = fn_query("SELECT code FROM functions WHERE id = ?", (function_id,))
if not rows:
raise KeyError(f"Funcion no encontrada: {function_id}")
return rows[0]["code"]
# ── Mensaje de bienvenida ───────────────────────────────────
print(f"fn_registry conectado: {FN_REGISTRY_ROOT}")
print(f" registry.db: {'OK' if _REGISTRY_DB.exists() else 'NO ENCONTRADO'}")
print(f" Python functions: {_pf}")
print(f" Helpers: fn_query(), fn_search(), fn_code()")
STARTUP
echo "${startup_dir}/00_fn_registry.py"
}
@@ -0,0 +1,33 @@
---
name: write_mcp_jupyter_config
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
description: "Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server apuntando al venv local y puerto dado. Merge con jq si ya existe."
tags: [mcp, jupyter, config, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_mcp_jupyter_config.sh"
---
## Ejemplo
```bash
source write_mcp_jupyter_config.sh
path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
echo "Config MCP en: $path"
```
## Notas
El MCP se invoca como modulo Python (`python -m jupyter_mcp_server`) usando el python del venv local, nunca una instalacion global. Si `.mcp.json` ya existe y jq esta disponible, hace merge conservando otros servidores MCP. Sin jq, sobrescribe el archivo.
@@ -0,0 +1,57 @@
# write_mcp_jupyter_config
# -------------------------
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
# Usa el python del venv local con -m jupyter_mcp_server.
# Hace merge si ya existe .mcp.json (requiere jq).
#
# USO (sourced):
# source write_mcp_jupyter_config.sh
# write_mcp_jupyter_config /path/to/project 8888
write_mcp_jupyter_config() {
local project_dir="${1:-.}"
local port="${2:-8888}"
local mcp_file="${project_dir}/.mcp.json"
local abs_project
abs_project="$(cd "$project_dir" && pwd)"
local python_bin="${abs_project}/.venv/bin/python"
if [ ! -f "$python_bin" ]; then
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
return 1
fi
# Verificar que el modulo esta instalado
if ! "$python_bin" -c "import jupyter_mcp_server" 2>/dev/null; then
echo "write_mcp_jupyter_config: jupyter_mcp_server no instalado en el venv" >&2
return 1
fi
local new_config
new_config=$(cat << EOF
{
"mcpServers": {
"jupyter": {
"command": "${python_bin}",
"args": [
"-m", "jupyter_mcp_server",
"--runtime-url", "http://localhost:${port}",
"--start-new-runtime", "false"
]
}
}
}
EOF
)
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
# Merge conservando otros servidores MCP
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) * (.[1].mcpServers // {}))}' \
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
mv "${mcp_file}.tmp" "$mcp_file"
else
echo "$new_config" > "$mcp_file"
fi
echo "$mcp_file"
}