# functions

id: wails_bind_crud_go_infra
name: wails_bind_crud
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: GenerateWailsCRUD(spec WailsCRUDSpec) string
tags: ["wails","crud","generator","bindings","codegen","infra"]
uses_functions: []
uses_types: []
returns: []
imports: ["fmt","strings"]
tests: []
file_path: functions/infra/wails_bind_crud.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 021352757307ee8e1012c7a7e7f3ed164224bb5a41d3d56a01c00ac71d8acdf5
description:
  Genera código Go de bindings CRUD para Wails: struct + métodos List/Get/Create/Update/Delete con stubs not-implemented.

example:
  ```go
  code := GenerateWailsCRUD(WailsCRUDSpec{
      EntityName: "User",
      Fields:     []string{"Name string", "Email string", "Role string"},
      WithList:   true,
      WithGet:    true,
      WithCreate: true,
      WithUpdate: true,
      WithDelete: true,
  })
  // Genera: type User struct + ListUsers + GetUser + CreateUser + UpdateUser + DeleteUser
  ```

notes:
  Función pura — genera código como string. Los métodos son stubs con `return fmt.Errorf("not implemented")`. Un agente puede generar el código, insertarlo en app.go, y luego implementar los TODOs.

code:
  package infra

  import (
  	"fmt"
  	"strings"
  )

  // WailsCRUDSpec define la especificación para generar bindings CRUD.
  type WailsCRUDSpec struct {
  	EntityName string   // Nombre de la entidad (PascalCase, ej: "User")
  	Fields     []string // Campos de la entidad (ej: ["Name string", "Email string"])
  	WithList   bool     // Generar List method
  	WithGet    bool     // Generar Get method
  	WithCreate bool     // Generar Create method
  	WithUpdate bool     // Generar Update method
  	WithDelete bool     // Generar Delete method
  }

  // GenerateWailsCRUD genera código Go de bindings CRUD para una entidad.
  // Retorna el código Go como string — función pura.
  func GenerateWailsCRUD(spec WailsCRUDSpec) string {
  	var sb strings.Builder
  	lower := strings.ToLower(spec.EntityName)
  	plural := lower + "s"

  	// Tipo
  	sb.WriteString(fmt.Sprintf("// %s entity\n", spec.EntityName))
  	sb.WriteString(fmt.Sprintf("type %s struct {\n", spec.EntityName))
  	sb.WriteString("\tID string `json:\"id\"`\n")
  	for _, field := range spec.Fields {
  		parts := strings.SplitN(field, " ", 2)
  		if len(parts) == 2 {
  			jsonTag := strings.ToLower(parts[0])
  			sb.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", parts[0], parts[1], jsonTag))
  		}
  	}
  	sb.WriteString("}\n\n")

  	// List
  	if spec.WithList {
  		sb.WriteString(fmt.Sprintf("// List%s retorna todos los %s.\n", spec.EntityName+"s", plural))
  		sb.WriteString(fmt.Sprintf("func (a *App) List%s() ([]%s, error) {\n", spec.EntityName+"s", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement List%s\n", spec.EntityName+"s"))
  		sb.WriteString(fmt.Sprintf("\treturn nil, fmt.Errorf(\"not implemented\")\n"))
  		sb.WriteString("}\n\n")
  	}

  	// Get
  	if spec.WithGet {
  		sb.WriteString(fmt.Sprintf("// Get%s retorna un %s por ID.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Get%s(id string) (%s, error) {\n", spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Get%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s{}, fmt.Errorf(\"not implemented\")\n", spec.EntityName))
  		sb.WriteString("}\n\n")
  	}

  	// Create
  	if spec.WithCreate {
  		sb.WriteString(fmt.Sprintf("// Create%s crea un nuevo %s.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Create%s(%s %s) (%s, error) {\n", spec.EntityName, lower, spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Create%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s, fmt.Errorf(\"not implemented\")\n", lower))
  		sb.WriteString("}\n\n")
  	}

  	// Update
  	if spec.WithUpdate {
  		sb.WriteString(fmt.Sprintf("// Update%s actualiza un %s existente.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Update%s(%s %s) (%s, error) {\n", spec.EntityName, lower, spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Update%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s, fmt.Errorf(\"not implemented\")\n", lower))
  		sb.WriteString("}\n\n")
  	}

  	// Delete
  	if spec.WithDelete {
  		sb.WriteString(fmt.Sprintf("// Delete%s elimina un %s por ID.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Delete%s(id string) error {\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Delete%s\n", spec.EntityName))
  		sb.WriteString("\treturn fmt.Errorf(\"not implemented\")\n")
  		sb.WriteString("}\n\n")
  	}

  	return sb.String()
  }

params_schema:
  {"params":[{"name":"spec","desc":"estructura WailsCRUDSpec con EntityName, Fields, WithList/Get/Create/Update/Delete flags"}],"output":"string con codigo Go generado (struct + metodos CRUD stubs)"}


id: wails_build_go_infra
name: wails_build
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsBuild(ctx context.Context, cfg WailsBuildConfig) error
tags: ["wails","build","compile","desktop","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os/exec","fmt","strings","context"]
tests: []
file_path: functions/infra/wails_build.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Requiere `wails` CLI instalado. WailsDev bloquea el proceso (es un servidor de desarrollo).
content_hash: 29867491260074c6a0c1bad048b1278cfd099a503463ac4fa26f4c0f30f3e703
description:
  Compila un proyecto Wails para linux/windows/darwin. Incluye WailsDev para modo desarrollo con hot reload.

example:
  ```go
  // Build para linux
  WailsBuild(ctx, WailsBuildConfig{
      Dir:      "/home/user/my-app",
      Platform: "linux",
  })

  // Desarrollo con hot reload + browser
  WailsDev(ctx, "/home/user/my-app", true)
  ```

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // WailsBuildConfig configura la compilación de un proyecto Wails.
  type WailsBuildConfig struct {
  	Dir      string // Directorio del proyecto Wails
  	Platform string // linux, windows, darwin (default: current)
  	Output   string // Nombre del binario de salida
  	Debug    bool   // Build con debug info
  }

  // WailsBuild compila un proyecto Wails para la plataforma especificada.
  func WailsBuild(ctx context.Context, cfg WailsBuildConfig) error {
  	if cfg.Dir == "" {
  		return fmt.Errorf("dir is required")
  	}

  	args := []string{"build"}

  	if cfg.Platform != "" {
  		args = append(args, "-platform", cfg.Platform)
  	}
  	if cfg.Output != "" {
  		args = append(args, "-o", cfg.Output)
  	}
  	if cfg.Debug {
  		args = append(args, "-debug")
  	}

  	cmd := exec.CommandContext(ctx, "wails", args...)
  	cmd.Dir = cfg.Dir

  	output, err := cmd.CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("wails build failed: %w\n%s", err, string(output))
  	}

  	fmt.Printf("Built successfully: %s\n", strings.TrimSpace(string(output)))
  	return nil
  }

  // WailsDev lanza un proyecto Wails en modo desarrollo con hot reload.
  func WailsDev(ctx context.Context, dir string, browser bool) error {
  	if dir == "" {
  		return fmt.Errorf("dir is required")
  	}

  	args := []string{"dev"}
  	if browser {
  		args = append(args, "-browser")
  	}

  	cmd := exec.CommandContext(ctx, "wails", args...)
  	cmd.Dir = dir
  	cmd.Stdout = nil // inherit
  	cmd.Stderr = nil

  	return cmd.Run()
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context para cancelacion"},{"name":"cfg","desc":"estructura WailsBuildConfig con Dir, Platform (linux/windows/darwin)"}],"output":"nil si la compilacion fue exitosa, error en caso contrario"}


id: wails_emit_event_go_infra
name: wails_emit_event
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsEmitEvent(ctx context.Context, eventName string, data interface{})
tags: ["wails","event","emit","ipc","realtime","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["context","encoding/json","fmt","time","github.com/wailsapp/wails/v2/pkg/runtime"]
tests: []
file_path: functions/infra/wails_emit_event.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 7f4a1a50609433623d66dbb19bd9ab8060876d0fa5456170cd4f4fad3a6ee74d
description:
  Emite eventos tipados de Go al frontend con timestamp automático. Incluye WailsEmitJSON para serialización explícita.

example:
  ```go
  // Emitir evento tipado
  WailsEmitEvent(ctx, "price:update", PriceData{Symbol: "BTC", Price: 67500.0})

  // El frontend lo recibe con useWailsEvent:
  // useWailsEvent({ eventName: 'price:update', onEvent: (data) => ... })
  ```

notes:
  El evento llega al frontend como WailsEventPayload con `type`, `data` y `timestamp`. Complementa `use_wails_event` del lado TS. Requiere la dependencia `github.com/wailsapp/wails/v2` en el proyecto destino.

code:
  //go:build ignore
  // NOTE: requires wails dependency in target project — use this file by copying into a Wails project

  package infra

  import (
  	"context"
  	"encoding/json"
  	"fmt"
  	"time"

  	"github.com/wailsapp/wails/v2/pkg/runtime"
  )

  // WailsEventPayload es un envelope para eventos tipados.
  type WailsEventPayload struct {
  	Type      string      `json:"type"`
  	Data      interface{} `json:"data"`
  	Timestamp time.Time   `json:"timestamp"`
  }

  // WailsEmitEvent emite un evento tipado al frontend.
  func WailsEmitEvent(ctx context.Context, eventName string, data interface{}) {
  	payload := WailsEventPayload{
  		Type:      eventName,
  		Data:      data,
  		Timestamp: time.Now(),
  	}
  	runtime.EventsEmit(ctx, eventName, payload)
  }

  // WailsEmitJSON emite datos como JSON string al frontend.
  func WailsEmitJSON(ctx context.Context, eventName string, data interface{}) error {
  	b, err := json.Marshal(data)
  	if err != nil {
  		return fmt.Errorf("marshaling event data: %w", err)
  	}
  	runtime.EventsEmit(ctx, eventName, string(b))
  	return nil
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context de Wails"},{"name":"eventName","desc":"nombre del evento a emitir (ej: price:update)"},{"name":"data","desc":"datos a serializar y enviar al frontend"}],"output":"void (sin valor de retorno, efecto es emitir el evento)"}


id: wails_stream_data_go_infra
name: wails_stream_data
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsStreamData[T any](ctx context.Context, cfg WailsStreamConfig, data []T) error
tags: ["wails","stream","ipc","realtime","chunks","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["context","fmt","time","github.com/wailsapp/wails/v2/pkg/runtime"]
tests: []
file_path: functions/infra/wails_stream_data.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 9e95a95081a919411bb80efa578abead56b149c821158ad68f847377ce859ae3
description:
  Envía datos como stream Go→TS con protocolo {name}/{name}:complete/{name}:error. Incluye WailsStreamFunc para generadores.

example:
  ```go
  // Stream de slice
  WailsStreamData(ctx, WailsStreamConfig{
      StreamName: "logs",
      ChunkDelay: 10 * time.Millisecond,
  }, logLines)

  // Stream con generador
  WailsStreamFunc(ctx, "metrics", func(ctx context.Context, ch chan<- Metric) error {
      for {
          select {
          case <-ctx.Done():
              return nil
          case <-time.After(time.Second):
              ch <- collectMetric()
          }
      }
  })

  // Frontend lo recibe con useWailsStream:
  // useWailsStream({ streamName: 'metrics', autoStart: true })
  ```

notes:
  Protocolo: chunks en `{streamName}`, fin en `{streamName}:complete`, error en `{streamName}:error`. Compatible con `use_wails_stream` del lado TS. WailsStreamFunc usa goroutine + canal para datos asíncronos. Requiere Go 1.18+ (generics) y la dependencia `github.com/wailsapp/wails/v2` en el proyecto destino.

code:
  //go:build ignore
  // NOTE: requires wails dependency in target project — use this file by copying into a Wails project

  package infra

  import (
  	"context"
  	"fmt"
  	"time"

  	"github.com/wailsapp/wails/v2/pkg/runtime"
  )

  // WailsStreamConfig configura un stream de datos hacia el frontend.
  type WailsStreamConfig struct {
  	StreamName string        // Nombre del stream (evento base)
  	ChunkDelay time.Duration // Delay entre chunks (default 0)
  }

  // WailsStreamData envía un slice de datos como stream al frontend.
  // Emite cada elemento como chunk en {streamName}, y {streamName}:complete al terminar.
  func WailsStreamData[T any](ctx context.Context, cfg WailsStreamConfig, data []T) error {
  	if cfg.StreamName == "" {
  		return fmt.Errorf("stream name is required")
  	}

  	for _, chunk := range data {
  		select {
  		case <-ctx.Done():
  			runtime.EventsEmit(ctx, cfg.StreamName+":error", map[string]string{
  				"message": "stream cancelled",
  			})
  			return ctx.Err()
  		default:
  			runtime.EventsEmit(ctx, cfg.StreamName, chunk)
  			if cfg.ChunkDelay > 0 {
  				time.Sleep(cfg.ChunkDelay)
  			}
  		}
  	}

  	runtime.EventsEmit(ctx, cfg.StreamName+":complete", nil)
  	return nil
  }

  // WailsStreamFunc ejecuta una función generadora y envía resultados como stream.
  // La función generadora envía datos por el canal, y esta función los retransmite al frontend.
  func WailsStreamFunc[T any](ctx context.Context, streamName string, generator func(ctx context.Context, ch chan<- T) error) error {
  	ch := make(chan T, 100)
  	errCh := make(chan error, 1)

  	go func() {
  		defer close(ch)
  		errCh <- generator(ctx, ch)
  	}()

  	for chunk := range ch {
  		runtime.EventsEmit(ctx, streamName, chunk)
  	}

  	if err := <-errCh; err != nil {
  		runtime.EventsEmit(ctx, streamName+":error", map[string]string{
  			"message": err.Error(),
  		})
  		return err
  	}

  	runtime.EventsEmit(ctx, streamName+":complete", nil)
  	return nil
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context de Wails"},{"name":"cfg","desc":"estructura WailsStreamConfig con StreamName y ChunkDelay"},{"name":"data","desc":"slice de datos genericos a enviar en chunks"}],"output":"nil si el stream se completo exitosamente, error en caso contrario"}


id: write_dockerfile_go_infra
name: write_dockerfile
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WriteDockerfile(dir, content string) (string, error)
tags: ["docker","dockerfile","io","write","deploy","infra"]
uses_functions: ["generate_dockerfile_go_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath"]
test_file_path: functions/infra/write_dockerfile_test.go
file_path: functions/infra/write_dockerfile.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: d2b96af570f088cb1ea5aeaf933ba61a5bfee8ccb2f0c08e7b91a6cc72c4aa0d
description:
  Escribe content en dir/Dockerfile. Crea el directorio si no existe. Retorna el path absoluto del archivo escrito. Compañera impura de generate_dockerfile.

example:
  ```go
  // Patron puro+impuro: generar contenido y luego escribir
  content := GenerateDockerfile("myapp", 8080, map[string]string{"PORT": "8080"})
  path, err := WriteDockerfile("/home/user/apps/myapp", content)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("Dockerfile escrito en:", path)
  // /home/user/apps/myapp/Dockerfile
  ```

tests:
  ["escribe Dockerfile en directorio existente","crea directorio si no existe","retorna path absoluto correcto","error si dir es path invalido"]

notes:
  Patron puro+impuro: generate_dockerfile produce el texto (pura, testeable sin I/O), write_dockerfile lo persiste (impura, efecto secundario aislado). Esto facilita testear la generacion del contenido independientemente de la escritura. Sobreescribe cualquier Dockerfile existente en el directorio.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  )

  // WriteDockerfile escribe content en dir/Dockerfile.
  // Crea el directorio si no existe. Retorna el path absoluto del archivo escrito.
  func WriteDockerfile(dir, content string) (string, error) {
  	absDir, err := filepath.Abs(dir)
  	if err != nil {
  		return "", fmt.Errorf("write_dockerfile: resolver path %s: %w", dir, err)
  	}

  	if err := os.MkdirAll(absDir, 0o755); err != nil {
  		return "", fmt.Errorf("write_dockerfile: crear directorio %s: %w", absDir, err)
  	}

  	dockerfilePath := filepath.Join(absDir, "Dockerfile")
  	if err := os.WriteFile(dockerfilePath, []byte(content), 0o644); err != nil {
  		return "", fmt.Errorf("write_dockerfile: escribir %s: %w", dockerfilePath, err)
  	}

  	return dockerfilePath, nil
  }

params_schema:
  {"params":[{"name":"dir","desc":"directorio donde crear el archivo Dockerfile (se crea si no existe)"},{"name":"content","desc":"contenido del Dockerfile como string"}],"output":"ruta absoluta del archivo Dockerfile escrito"}


id: docker_tui_go_infra
name: docker_tui
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func main() — TUI fullscreen para gestionar Docker
tags: ["docker","tui","pipeline","infra","bubbletea","devfactory"]
returns: []
error_type: error_go_core
imports: ["github.com/charmbracelet/bubbletea","github.com/lucasdataproyects/devfactory"]
example:
  ```bash
  cd apps/docker_tui && go run .
  ```

tests: []
file_path: apps/docker_tui/main.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-07T20:58:02Z
props: []
emits: []
variant: []
content_hash: a648b5553e92e62da99149111d1f2a892733b254a8ca6317b3ee5371767e68df
description:
  Pipeline que compone componentes TUI de DevFactory con comandos Docker para crear una aplicacion de terminal interactiva. Gestiona containers, images, volumes, networks y compose.

uses_functions:
  ["new_filtered_list_go_tui","new_list_go_tui","new_spinner_go_tui","new_base_model_go_tui","default_styles_go_tui","run_fullscreen_go_tui","run_cmd_timeout_go_shell","docker_list_containers_go_infra","docker_start_container_go_infra","docker_stop_container_go_infra","docker_container_logs_go_infra","docker_list_images_go_infra","docker_remove_image_go_infra"]

uses_types:
  ["container_go_docker","image_go_docker","volume_go_docker","network_go_docker","compose_project_go_docker","list_model_go_tui","filtered_list_model_go_tui","spinner_model_go_tui","styles_go_tui","base_model_go_tui","cmd_result_go_shell"]

notes:
  Pipeline principal del proyecto Docker TUI. Compone funciones de los dominios tui, shell e infra
  para crear una aplicacion de terminal con 5 vistas: Containers (start/stop/restart/logs),
  Images (list/remove), Volumes (list/remove), Networks (list/remove), Compose (up/down/logs).

  Arquitectura Elm (Model-Update-View) con navegacion por tabs y state machines por vista.

code:
  package main

  import (
  	"fmt"
  	"os"

  	"docker-tui/app"

  	"github.com/lucasdataproyects/devfactory/tui"
  )

  func main() {
  	model := app.New()
  	result := tui.RunFullscreen(model)

  	if result.IsErr() {
  		fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
  		os.Exit(1)
  	}
  }

params_schema:
  {"output":"Ejecutable TUI fullscreen que gestiona Docker containers, images, volumes, networks y compose"}


id: init_metabase_go_infra
name: init_metabase
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func main() — Despliega stack Metabase + Postgres en Docker
tags: ["docker","metabase","postgres","pipeline","infra","analytics","launcher"]
uses_types: ["container_info_go_infra","network_go_docker"]
returns: []
error_type: error_go_core
imports: ["os/exec","encoding/json"]
tests: []
file_path: functions/pipelines/init_metabase/main.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 11062eda7f4a4c138360635c05232d7271d7624bf0742e2923c642c126376860
params_schema: {"output":"stdout: JSON con IDs de red, contenedores (postgres, metabase) y URL de acceso"}
description:
  Pipeline que inicializa un contenedor Metabase con su base de datos Postgres. Crea red Docker, pull de imágenes, inicia Postgres con volume persistente, espera health check y lanza Metabase conectado.

uses_functions:
  ["docker_create_network_go_infra","docker_pull_image_go_infra","docker_run_container_go_infra","docker_inspect_container_go_infra","retry_with_backoff_go_core"]

example:
  ```bash
  # Básico
  go run functions/pipelines/init_metabase/main.go \
    --project analytics \
    --metabase-port 3000 \
    --pg-user metabase \
    --pg-password metabase

  # Con volume para registry.db (detecta cambios en vivo)
  go run functions/pipelines/init_metabase/main.go \
    --project fn_registry \
    --mb-volumes "/home/lucas/fn_registry/registry.db:/data/registry.db"
  ```

  Salida JSON:
  ```json
  {
    "network_id": "abc123...",
    "postgres_id": "def456...",
    "metabase_id": "ghi789...",
    "network_name": "analytics-net",
    "postgres_name": "analytics-postgres",
    "metabase_name": "analytics-metabase",
    "metabase_url": "http://localhost:3000"
  }
  ```

notes:
  El pipeline orquesta 5 pasos secuenciales:

  1. **Red Docker** — crea `{project}-net` con driver bridge
  2. **Pull** — descarga `postgres:16` y `metabase/metabase:latest`
  3. **Postgres** — inicia con volume persistente (named volume por defecto o bind mount con `--pg-volume`)
  4. **Health check** — retry exponencial (hasta ~34 min) con `pg_isready` dentro del contenedor
  5. **Metabase** — conecta a Postgres via red interna, expone en puerto configurable. Con `--mb-volumes` monta volumes adicionales (ej: registry.db para SQLite)
  6. **Permisos** — ajusta ownership de directorios montados para el usuario `metabase` (UID 2000) dentro del contenedor

  Reutiliza conceptualmente `docker_create_network`, `docker_pull_image`, `docker_run_container`, `docker_inspect_container` y `retry_with_backoff`, reimplementadas inline por ser un ejecutable independiente.

  Para destruir el stack:
  ```bash
  docker stop analytics-metabase analytics-postgres
  docker rm analytics-metabase analytics-postgres
  docker network rm analytics-net
  ```

code:
  package main

  import (
  	"encoding/json"
  	"flag"
  	"fmt"
  	"os"
  	"os/exec"
  	"strings"
  	"time"
  )

  // MetabaseResult contiene los IDs y nombres de los recursos creados.
  type MetabaseResult struct {
  	NetworkID    string `json:"network_id"`
  	PostgresID   string `json:"postgres_id"`
  	MetabaseID   string `json:"metabase_id"`
  	NetworkName  string `json:"network_name"`
  	PostgresName string `json:"postgres_name"`
  	MetabaseName string `json:"metabase_name"`
  	MetabaseURL  string `json:"metabase_url"`
  }

  func main() {
  	project := flag.String("project", "", "Prefijo para nombres de contenedores y red (requerido)")
  	mbPort := flag.String("metabase-port", "3000", "Puerto host para Metabase")
  	pgPort := flag.String("pg-port", "5432", "Puerto host para Postgres")
  	pgUser := flag.String("pg-user", "metabase", "Usuario Postgres")
  	pgPass := flag.String("pg-password", "metabase", "Password Postgres")
  	pgDB := flag.String("pg-database", "metabase", "Base de datos Postgres")
  	pgVolume := flag.String("pg-volume", "", "Path host para persistencia Postgres (default: docker named volume)")
  	mbVolumes := flag.String("mb-volumes", "", "Volumes adicionales para Metabase, separados por coma (ej: /host/path:/container/path,/otro:/dest)")
  	flag.Parse()

  	if *project == "" {
  		fmt.Fprintln(os.Stderr, "error: --project requerido")
  		flag.Usage()
  		os.Exit(1)
  	}

  	var extraVolumes []string
  	if *mbVolumes != "" {
  		extraVolumes = strings.Split(*mbVolumes, ",")
  	}

  	result, err := initMetabase(*project, *mbPort, *pgPort, *pgUser, *pgPass, *pgDB, *pgVolume, extraVolumes)
  	if err != nil {
  		fmt.Fprintf(os.Stderr, "error: %v\n", err)
  		os.Exit(1)
  	}

  	enc := json.NewEncoder(os.Stdout)
  	enc.SetIndent("", "  ")
  	enc.Encode(result)
  }

  func initMetabase(project, mbPort, pgPort, pgUser, pgPass, pgDB, pgVolume string, mbExtraVolumes []string) (*MetabaseResult, error) {
  	networkName := project + "-net"
  	pgName := project + "-postgres"
  	mbName := project + "-metabase"

  	// 1. Crear red
  	fmt.Fprintf(os.Stderr, "[1/5] Creando red %s...\n", networkName)
  	netID, err := dockerCreateNetwork(networkName, "bridge")
  	if err != nil {
  		return nil, fmt.Errorf("creating network: %w", err)
  	}

  	// 2. Pull imágenes
  	for i, img := range []string{"postgres:16", "metabase/metabase:latest"} {
  		fmt.Fprintf(os.Stderr, "[2/5] Pulling %s (%d/2)...\n", img, i+1)
  		if err := dockerPull(img); err != nil {
  			return nil, fmt.Errorf("pulling %s: %w", img, err)
  		}
  	}

  	// 3. Iniciar Postgres
  	fmt.Fprintf(os.Stderr, "[3/5] Iniciando Postgres %s...\n", pgName)
  	vol := pgName + "-data:/var/lib/postgresql/data"
  	if pgVolume != "" {
  		vol = pgVolume + ":/var/lib/postgresql/data"
  	}

  	pgArgs := []string{
  		"run", "-d",
  		"--name", pgName,
  		"--network", networkName,
  		"-p", pgPort + ":5432",
  		"-e", "POSTGRES_USER=" + pgUser,
  		"-e", "POSTGRES_PASSWORD=" + pgPass,
  		"-e", "POSTGRES_DB=" + pgDB,
  		"-v", vol,
  		"postgres:16",
  	}
  	pgID, err := dockerCmd(pgArgs...)
  	if err != nil {
  		return nil, fmt.Errorf("starting postgres: %w", err)
  	}

  	// 4. Health check con retry exponencial
  	fmt.Fprintf(os.Stderr, "[4/5] Esperando a que Postgres esté listo...\n")
  	err = retryWithBackoff(func() error {
  		out, execErr := dockerCmd("exec", pgName, "pg_isready", "-U", pgUser)
  		if execErr != nil {
  			return fmt.Errorf("pg_isready: %s", out)
  		}
  		return nil
  	}, 10, 2*time.Second)
  	if err != nil {
  		return nil, fmt.Errorf("waiting for postgres: %w", err)
  	}
  	fmt.Fprintf(os.Stderr, "       Postgres listo.\n")

  	// 5. Iniciar Metabase
  	fmt.Fprintf(os.Stderr, "[5/5] Iniciando Metabase %s...\n", mbName)
  	mbArgs := []string{
  		"run", "-d",
  		"--name", mbName,
  		"--network", networkName,
  		"-p", mbPort + ":3000",
  		"-e", "MB_DB_TYPE=postgres",
  		"-e", "MB_DB_DBNAME=" + pgDB,
  		"-e", "MB_DB_PORT=5432",
  		"-e", "MB_DB_USER=" + pgUser,
  		"-e", "MB_DB_PASS=" + pgPass,
  		"-e", "MB_DB_HOST=" + pgName,
  	}
  	for _, v := range mbExtraVolumes {
  		mbArgs = append(mbArgs, "-v", strings.TrimSpace(v))
  	}
  	mbArgs = append(mbArgs, "metabase/metabase:latest")
  	mbID, err := dockerCmd(mbArgs...)
  	if err != nil {
  		return nil, fmt.Errorf("starting metabase: %w", err)
  	}

  	// 6. Fix permisos para volumes SQLite — Metabase corre como UID 2000
  	for _, v := range mbExtraVolumes {
  		parts := strings.SplitN(strings.TrimSpace(v), ":", 2)
  		if len(parts) < 2 {
  			continue
  		}
  		destPath := parts[1]
  		// Si es un archivo, fix el directorio padre; si es directorio, fix directo
  		dir := destPath
  		if strings.Contains(destPath, ".") {
  			// Probablemente un archivo, usar dirname
  			idx := strings.LastIndex(destPath, "/")
  			if idx > 0 {
  				dir = destPath[:idx]
  			}
  		}
  		fmt.Fprintf(os.Stderr, "       Fijando permisos de %s para usuario metabase...\n", dir)
  		dockerCmd("exec", "-u", "root", mbName, "chown", "metabase:metabase", dir)
  	}

  	mbURL := fmt.Sprintf("http://localhost:%s", mbPort)
  	fmt.Fprintf(os.Stderr, "\nStack listo. Metabase disponible en %s\n", mbURL)

  	return &MetabaseResult{
  		NetworkID:    netID,
  		PostgresID:   pgID,
  		MetabaseID:   mbID,
  		NetworkName:  networkName,
  		PostgresName: pgName,
  		MetabaseName: mbName,
  		MetabaseURL:  mbURL,
  	}, nil
  }

  // --- Funciones Docker (reimplementan las del registry sin imports internos) ---

  func dockerCreateNetwork(name, driver string) (string, error) {
  	return dockerCmd("network", "create", "--driver", driver, name)
  }

  func dockerPull(image string) error {
  	_, err := dockerCmd("pull", image)
  	return err
  }

  func dockerCmd(args ...string) (string, error) {
  	out, err := exec.Command("docker", args...).CombinedOutput()
  	if err != nil {
  		return "", fmt.Errorf("docker %s: %s", args[0], strings.TrimSpace(string(out)))
  	}
  	return strings.TrimSpace(string(out)), nil
  }

  // retryWithBackoff reimplementa RetryWithBackoff de core para funciones () error.
  func retryWithBackoff(fn func() error, maxRetries int, baseDelay time.Duration) error {
  	var lastErr error
  	for attempt := 0; attempt <= maxRetries; attempt++ {
  		if err := fn(); err == nil {
  			return nil
  		} else {
  			lastErr = err
  		}
  		if attempt < maxRetries {
  			delay := baseDelay * (1 << uint(attempt))
  			time.Sleep(delay)
  		}
  	}
  	return fmt.Errorf("all %d retries exhausted: %w", maxRetries+1, lastErr)
  }


id: pipeline_launcher_go_infra
name: pipeline_launcher
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func main() — TUI fullscreen para lanzar pipelines y registrar ejecuciones
tags: ["tui","pipeline","infra","operations","bubbletea","devfactory"]
uses_functions: ["docker_tui_go_infra"]
uses_types: ["base_model_go_tui","filtered_list_model_go_tui","spinner_model_go_tui","styles_go_tui"]
returns: []
error_type: error_go_core
example:
  ```bash
  FN_REGISTRY_ROOT=/home/lucas/fn_registry go run apps/pipeline_launcher/main.go
  ```

tests: []
file_path: apps/pipeline_launcher/main.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-07T20:58:02Z
props: []
emits: []
variant: []
content_hash: 3ce494526fbad4b8832594a7f4cb089ee226f2b98d24a04a3e681a3736266e84
description:
  TUI interactiva que lista pipelines del registry, permite lanzarlos como subprocesos y registra cada ejecución en operations.db. Dos tabs: Pipelines (filtro + launch) y History (historial).

imports:
  ["github.com/charmbracelet/bubbletea","github.com/lucasdataproyects/devfactory","fn-registry/registry","fn-registry/fn_operations"]

notes:
  TUI con dos tabs:

  - **Pipelines**: lista filtrable de todos los pipelines del registry. Enter lanza el pipeline seleccionado como subproceso (`go run .`), muestra spinner durante la ejecución, y al terminar muestra stdout/stderr y registra la ejecución en operations.db.
  - **History**: historial de ejecuciones pasadas con status, duración y timestamp. Enter muestra detalle completo.

  Teclas: Tab/Shift+Tab cambia vista, Enter ejecuta acción, r refresca, / filtra, j/k scroll, Esc vuelve.

  Requiere `FN_REGISTRY_ROOT` apuntando a la raíz del fn-registry para encontrar registry.db y los file_path de los pipelines.

code:
  package main

  import (
  	"fmt"
  	"os"

  	"pipeline-launcher/app"
  	"pipeline-launcher/config"

  	"github.com/lucasdataproyects/devfactory/tui"
  )

  func main() {
  	cfg := config.Default()

  	model, err := app.New(cfg)
  	if err != nil {
  		fmt.Fprintf(os.Stderr, "error: %v\n", err)
  		os.Exit(1)
  	}
  	defer model.Close()

  	result := tui.RunFullscreen(model)

  	if result.IsErr() {
  		fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
  		os.Exit(1)
  	}
  }

params_schema:
  {"output":"Ejecutable TUI fullscreen con tabs de Pipelines (lista filtrable + launch) e History (ejecuciones)"}


id: run_cmd_go_shell
name: run_cmd
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func Run(name string, args ...string) core.Result[CmdResult]
description: Ejecuta un comando del sistema con timeout de 30 segundos y devuelve el resultado.
tags: ["shell","command","process","exec"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: []
example:
  ```go
  result := Run("ls", "-la")
  ```

tests: []
file_path: functions/shell/run_cmd.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: 03340d58d926e39caea6d195fadcf209093ccb2b0438e71a036afd5293f71b85
code:
  package shell

  import "fmt"

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // Run ejecuta un comando del sistema con timeout de 30 segundos y devuelve el resultado.
  func Run(name string, args ...string) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"name","desc":"nombre del comando (ej: ls, docker)"},{"name":"args","desc":"argumentos del comando (variadic)"}],"output":"Result[CmdResult] con stdout, stderr, exit code"}


id: run_cmd_timeout_go_shell
name: run_cmd_timeout
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func RunWithTimeout(name string, timeout time.Duration, args ...string) core.Result[CmdResult]
description: Ejecuta un comando del sistema con timeout configurable.
tags: ["shell","command","process","exec","timeout"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["time"]
example:
  ```go
  result := RunWithTimeout("curl", 5*time.Second, "https://example.com")
  ```

tests: []
file_path: functions/shell/run_cmd_timeout.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: cbba369224f509041bb020894e57fe0c1055098baed819d148b2056f5dd49933
code:
  package shell

  import (
  	"fmt"
  	"time"
  )

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // RunWithTimeout ejecuta un comando del sistema con timeout configurable.
  func RunWithTimeout(name string, timeout time.Duration, args ...string) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"name","desc":"nombre del comando"},{"name":"timeout","desc":"duración máxima de ejecución"},{"name":"args","desc":"argumentos del comando (variadic)"}],"output":"Result[CmdResult] con stdout, stderr, exit code"}


id: run_pipe_go_shell
name: run_pipe
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func RunPipe(commands ...string) core.Result[CmdResult]
description: Encadena multiples comandos con pipe y devuelve el resultado final.
tags: ["shell","command","process","pipe"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: []
example:
  ```go
  result := RunPipe("ls -la", "grep go", "wc -l")
  ```

tests: []
file_path: functions/shell/run_pipe.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: 1bd58988ccad1134eedae63eb5fe7983e4101f99bea29539cf6c6f624c17304e
code:
  package shell

  import "fmt"

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // RunPipe encadena multiples comandos con pipe y devuelve el resultado final.
  func RunPipe(commands ...string) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"commands","desc":"comandos a encadenar con pipe (variadic)"}],"output":"Result[CmdResult] con output combinado y exit code"}


id: run_shell_go_shell
name: run_shell
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func RunShell(command string) core.Result[CmdResult]
description: Ejecuta un comando shell interpretado por /bin/sh.
tags: ["shell","command","process","exec"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: []
example:
  ```go
  result := RunShell("ls -la | grep go")
  ```

tests: []
file_path: functions/shell/run_shell.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: c8c189fac95f16cb8188717582523dd2be4b8438ff70d74de5ad2f0c43e7c3e6
code:
  package shell

  import "fmt"

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // RunShell ejecuta un comando shell interpretado por /bin/sh.
  func RunShell(command string) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"command","desc":"comando shell a ejecutar (ej: ls -la | grep go)"}],"output":"Result[CmdResult] con stdout, stderr, exit code"}


id: run_shell_timeout_go_shell
name: run_shell_timeout
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func RunShellTimeout(command string, timeout time.Duration) core.Result[CmdResult]
description: Ejecuta un comando shell con timeout configurable.
tags: ["shell","command","process","exec","timeout"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["time"]
example:
  ```go
  result := RunShellTimeout("long-running-cmd", 30*time.Second)
  ```

tests: []
file_path: functions/shell/run_shell_timeout.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: 70df63fea1333e520a872aea87564bef8fb602062677794be8fdd4e68d34ee81
code:
  package shell

  import (
  	"fmt"
  	"time"
  )

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // RunShellTimeout ejecuta un comando shell con timeout configurable.
  func RunShellTimeout(command string, timeout time.Duration) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"command","desc":"comando shell a ejecutar"},{"name":"timeout","desc":"duración máxima de ejecución"}],"output":"Result[CmdResult] con stdout, stderr, exit code"}


id: which_go_shell
name: which
kind: function
lang: go
domain: shell
version: 1.0.0
purity: pure
signature: func Which(name string) core.Option[string]
description: Busca la ruta de un ejecutable en el PATH del sistema. Devuelve None si no existe.
tags: ["shell","path","lookup"]
uses_functions: []
uses_types: ["option_go_core"]
returns: ["option_go_core"]
imports: []
example:
  ```go
  path := Which("docker")
  ```

tests: []
file_path: functions/shell/which.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: 54b406f02ed0cbadbc47f6a5ad64c4fce4399be7bcbf59bdc56eceb5fb162965
code:
  package shell

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // Which busca la ruta de un ejecutable en el PATH del sistema. Devuelve ("", false) si no existe.
  func Which(name string) (string, bool) {
  	// stub — implementation in devfactory/shell
  	return "", false
  }

params_schema:
  {"params":[{"name":"name","desc":"nombre del ejecutable a buscar"}],"output":"Option[string]: Some(ruta_absoluta) o None si no existe"}


id: clear_screen_go_tui
name: clear_screen
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func ClearScreen() string
description: Devuelve el codigo de escape ANSI para limpiar la pantalla del terminal.
tags: ["tui","ansi","terminal","screen"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: functions/tui/clear_screen.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 7bec2db2888a90720d6fd92350ab025292de2588b6c76d269336d3a729fd40b4
params_schema: {"output":"string: código ANSI para limpiar pantalla"}
example:
  ```go
  fmt.Print(ClearScreen())
  // Limpia toda la pantalla y mueve el cursor a la posicion inicial
  ```

notes:
  Combina ESC[2J (borrar pantalla completa) con ESC[H (mover cursor a home). No tiene side effects: solo devuelve el string ANSI.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // ClearScreen devuelve el codigo de escape ANSI para limpiar la pantalla del terminal.
  func ClearScreen() string {
  	return "\033[2J\033[H"
  }


id: confirm_prompt_go_tui
name: confirm_prompt
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func Confirm(prompt string) core.Result[bool]
description: Muestra un dialogo de confirmacion Si/No en terminal y devuelve la eleccion del usuario.
tags: ["tui","confirm","prompt","interactive"]
uses_functions: []
uses_types: ["result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: []
tests: []
file_path: functions/tui/confirm_prompt.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: d495d64f37038962ca2a7654f2d55cee4c42a0fdb2639b22561e191c73182859
example:
  ```go
  result := tui.Confirm("¿Deseas continuar?")
  if result.IsErr() {
      log.Fatal(result.Error())
  }
  if result.Unwrap() {
      fmt.Println("Confirmado")
  }
  ```

notes:
  Implementacion real en github.com/lucasdataproyects/devfactory/tui. Este stub existe para documentar la firma y mantener el registry.

code:
  package tui

  import "fmt"

  // Confirm muestra un prompt de confirmacion si/no en la terminal.
  func Confirm(prompt string) (bool, error) {
  	// stub — implementation in devfactory/tui
  	return false, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"prompt","desc":"pregunta a mostrar al usuario"}],"output":"Result[bool]: true si confirmó, false si rechazó"}


id: dark_styles_go_tui
name: dark_styles
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DarkStyles() Styles
tags: ["tui","styles","dark","constructor"]
uses_functions: []
uses_types: []
returns: ["styles_go_tui"]
imports: []
example:
  ```go
  styles := DarkStyles()
  model := NewProgressWithStyles(100.0, "Compilando", styles)
  ```

tests: []
file_path: functions/tui/dark_styles.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Equivalente a NewStyles(DarkTheme()). Usa esta funcion para aplicaciones TUI en terminales oscuras.
content_hash: ca82d3010ff3f40dc80878727ad3d86bf01fa0c8d015ef356e42f6fd13eef623
params_schema: {"output":"Styles: conjunto de estilos lipgloss oscuros"}
description:
  Construye estilos oscuros combinando DarkTheme con NewStyles. Atajo conveniente para terminales con fondo negro.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // DarkStyles crea estilos oscuros usando el tema dark.
  func DarkStyles() Styles {
  	// stub — implementation in devfactory/tui
  	return Styles{}
  }


id: dark_theme_go_tui
name: dark_theme
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DarkTheme() Theme
tags: ["tui","theme","colors","dark","constructor"]
uses_functions: []
uses_types: []
returns: ["theme_go_tui"]
imports: []
example:
  ```go
  theme := DarkTheme()
  styles := NewStyles(theme)
  ```

tests: []
file_path: functions/tui/dark_theme.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Alternativa oscura al DefaultTheme. Colores de alto contraste para fondos negros.
content_hash: 41b83b51cb49a2ce8a1527c1bc259642774f56566a667f52f394cddc80c5c0d0
params_schema: {"output":"Theme: paleta de colores oscura"}
description:
  Construye un tema de colores oscuro para componentes TUI. Paleta optimizada para terminales con fondo negro.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // DarkTheme crea un tema de colores oscuro.
  func DarkTheme() Theme {
  	// stub — implementation in devfactory/tui
  	return Theme{}
  }


id: default_styles_go_tui
name: default_styles
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DefaultStyles() Styles
tags: ["tui","styles","constructor"]
uses_functions: []
uses_types: []
returns: ["styles_go_tui"]
imports: []
example:
  ```go
  styles := DefaultStyles()
  model := NewSpinnerWithStyle("Cargando...", styles)
  ```

tests: []
file_path: functions/tui/default_styles.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Equivalente a NewStyles(DefaultTheme()). Usa esta funcion cuando no necesitas personalizar el tema.
content_hash: b11ea0f07200b67b574f431bfe016933ad498273724be658dda7adb9ddde3b2b
params_schema: {"output":"Styles: conjunto de estilos lipgloss por defecto"}
description:
  Construye estilos por defecto combinando DefaultTheme con NewStyles. Atajo conveniente para el caso comun.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // DefaultStyles crea estilos por defecto usando el tema base.
  func DefaultStyles() Styles {
  	// stub — implementation in devfactory/tui
  	return Styles{}
  }


id: default_theme_go_tui
name: default_theme
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DefaultTheme() Theme
tags: ["tui","theme","colors","constructor"]
uses_functions: []
uses_types: []
returns: ["theme_go_tui"]
imports: []
example:
  ```go
  theme := DefaultTheme()
  styles := NewStyles(theme)
  ```

tests: []
file_path: functions/tui/default_theme.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Tema base del sistema de estilos. Usar DarkTheme para terminales con fondo oscuro.
content_hash: b0b3e6c5e61df5ea9974ae34e5458a027fb4170e77b619497a40ab2230212a6e
params_schema: {"output":"Theme: paleta de colores clara/por defecto"}
description:
  Construye el tema de colores por defecto para componentes TUI. Paleta clara optimizada para terminales con fondo blanco.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // DefaultTheme crea el tema de colores por defecto.
  func DefaultTheme() Theme {
  	// stub — implementation in devfactory/tui
  	return Theme{}
  }


id: hide_cursor_go_tui
name: hide_cursor
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func HideCursor() string
description: Devuelve el codigo de escape ANSI para ocultar el cursor del terminal.
tags: ["tui","ansi","terminal","cursor"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```go
  fmt.Print(HideCursor())
  // Oculta el cursor del terminal usando DECTCEM
  ```

tests: []
file_path: functions/tui/hide_cursor.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 417ef4fa35e41278599091250fbe7e8bdec80f667cd229353ef7d0297c0729ae
params_schema: {"output":"string: código ANSI para ocultar el cursor"}
notes:
  Usa la secuencia DECTCEM (ESC[?25l) del estandar VT220. Recordar restaurar el cursor con ShowCursor al finalizar la aplicacion.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // HideCursor devuelve el codigo de escape ANSI para ocultar el cursor del terminal.
  func HideCursor() string {
  	return "\033[?25l"
  }


id: new_base_model_go_tui
name: new_base_model
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewBaseModel() BaseModel
tags: ["tui","base","constructor"]
uses_functions: []
uses_types: []
returns: ["base_model_go_tui"]
imports: []
example:
  ```go
  base := NewBaseModel()
  ```

tests: []
file_path: functions/tui/new_base_model.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: e2707cafbeaea45e60fd402191df039f97ffb6c17cb9e0bc10e5ab07c630b48d
params_schema: {"output":"BaseModel: modelo base con dimensiones y estilos"}
description:
  Construye un modelo base con dimensiones de terminal y estilos por defecto. Sirve como fundacion para componer modelos mas complejos.

notes:
  Modelo raiz que encapsula estado comun (dimensiones, estilos, keybindings) compartido por todos los componentes TUI.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewBaseModel crea un modelo base con configuracion por defecto.
  func NewBaseModel() BaseModel {
  	// stub — implementation in devfactory/tui
  	return BaseModel{}
  }


id: new_confirm_go_tui
name: new_confirm
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewConfirm(prompt string) ConfirmModel
description: Construye un modelo de dialogo de confirmacion con una pregunta si/no.
tags: ["tui","confirm","dialog","constructor"]
uses_functions: []
uses_types: []
returns: ["confirm_model_go_tui"]
imports: []
example:
  ```go
  model := NewConfirm("Deseas continuar?")
  ```

tests: []
file_path: functions/tui/new_confirm.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: bbdfb649d61ed0f2c6b69722d2ccd01bb1c689f1de930c1900167028e97cdc4e
notes:
  Dialogo binario si/no. El resultado se obtiene del modelo tras la interaccion del usuario en el loop de Bubble Tea.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewConfirm crea un modelo de dialogo de confirmacion si/no.
  func NewConfirm(prompt string) ConfirmModel {
  	// stub — implementation in devfactory/tui
  	return ConfirmModel{}
  }

params_schema:
  {"params":[{"name":"prompt","desc":"pregunta a mostrar en el diálogo"}],"output":"ConfirmModel: modelo de confirmación interactivo"}


id: new_filtered_list_go_tui
name: new_filtered_list
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewFilteredList(items []ListItem, placeholder string) FilteredListModel
tags: ["tui","list","filter","constructor"]
uses_functions: []
uses_types: ["list_item_go_tui"]
returns: ["filtered_list_model_go_tui"]
imports: []
tests: []
file_path: functions/tui/new_filtered_list.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Incluye un input de texto que filtra los items en tiempo real por coincidencia parcial.
content_hash: 06d931fd24b4712da8e2331b0f279d06b5c72a25c822e12c16bf4f812e4fe21f
description:
  Construye un modelo de lista con campo de busqueda integrado. El placeholder se muestra en el input de filtro cuando esta vacio.

example:
  ```go
  items := []ListItem{
      {Title: "main.go", Description: "Punto de entrada"},
      {Title: "server.go", Description: "Servidor HTTP"},
  }
  model := NewFilteredList(items, "Buscar archivo...")
  ```

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewFilteredList crea un modelo de lista con filtrado por texto.
  func NewFilteredList(items []ListItem, placeholder string) FilteredListModel {
  	// stub — implementation in devfactory/tui
  	return FilteredListModel{}
  }

params_schema:
  {"params":[{"name":"items","desc":"array de ListItem con título y descripción"},{"name":"placeholder","desc":"texto a mostrar en el input de filtro vacío"}],"output":"FilteredListModel: lista con búsqueda en tiempo real"}


id: new_list_go_tui
name: new_list
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewList(items []ListItem) ListModel
tags: ["tui","list","constructor"]
uses_functions: []
uses_types: ["list_item_go_tui"]
returns: ["list_model_go_tui"]
imports: []
tests: []
file_path: functions/tui/new_list.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 172cc7c4e96cdc34dc4041c45e6ea5e8893733d4a3c11e0173e495017800fd03
description:
  Construye un modelo de lista simple a partir de una coleccion de items. Cada item se renderiza como una fila seleccionable.

example:
  ```go
  items := []ListItem{
      {Title: "Opcion 1", Description: "Primera opcion"},
      {Title: "Opcion 2", Description: "Segunda opcion"},
  }
  model := NewList(items)
  ```

notes:
  Constructor puro para listas de seleccion simple. No permite seleccion multiple — usar NewMultiList para eso.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewList crea un modelo de lista simple a partir de items.
  func NewList(items []ListItem) ListModel {
  	// stub — implementation in devfactory/tui
  	return ListModel{}
  }

params_schema:
  {"params":[{"name":"items","desc":"array de ListItem para seleccionar"}],"output":"ListModel: lista simple seleccionable"}


id: new_multi_list_go_tui
name: new_multi_list
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewMultiList(items []ListItem) ListModel
tags: ["tui","list","multi","constructor"]
uses_functions: []
uses_types: ["list_item_go_tui"]
returns: ["list_model_go_tui"]
imports: []
tests: []
file_path: functions/tui/new_multi_list.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Variante de lista que habilita seleccion multiple con toggles por item.
content_hash: f8f97606e363475c1bf31e0d91573e7b90a594a3eb11a94e5c7a5797bf3bbe01
description:
  Construye un modelo de lista con seleccion multiple. Permite al usuario marcar varios items antes de confirmar.

example:
  ```go
  items := []ListItem{
      {Title: "Go", Description: "Lenguaje compilado"},
      {Title: "Python", Description: "Lenguaje interpretado"},
      {Title: "Rust", Description: "Lenguaje de sistemas"},
  }
  model := NewMultiList(items)
  ```

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewMultiList crea un modelo de lista con seleccion multiple.
  func NewMultiList(items []ListItem) ListModel {
  	// stub — implementation in devfactory/tui
  	return ListModel{}
  }

params_schema:
  {"params":[{"name":"items","desc":"array de ListItem para seleccionar múltiples"}],"output":"ListModel: lista con selección múltiple"}


id: new_multi_progress_go_tui
name: new_multi_progress
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewMultiProgress() MultiProgressModel
tags: ["tui","progress","multi","constructor"]
uses_functions: []
uses_types: []
returns: ["multi_progress_model_go_tui"]
imports: []
example:
  ```go
  model := NewMultiProgress()
  ```

tests: []
file_path: functions/tui/new_multi_progress.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 01b0fbee65c138ee46f9752e5b5969ec134d4c6bc407d47ce5bf813337773e2f
params_schema: {"output":"MultiProgressModel: contenedor para múltiples barras de progreso"}
description:
  Construye un modelo de progreso multiple vacio. Las barras individuales se agregan posteriormente via mensajes.

notes:
  Inicia sin barras. Cada barra se agrega dinamicamente via el loop de Bubble Tea, ideal para descargas paralelas o tareas concurrentes.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewMultiProgress crea un modelo de progreso multiple sin barras iniciales.
  func NewMultiProgress() MultiProgressModel {
  	// stub — implementation in devfactory/tui
  	return MultiProgressModel{}
  }


id: new_progress_go_tui
name: new_progress
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewProgress(total float64, label string) ProgressModel
description: Construye un modelo de barra de progreso con valor total y etiqueta descriptiva.
tags: ["tui","progress","constructor"]
uses_functions: []
uses_types: []
returns: ["progress_model_go_tui"]
imports: []
example:
  ```go
  model := NewProgress(100.0, "Descargando archivos")
  ```

tests: []
file_path: functions/tui/new_progress.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: El progreso inicia en 0. El avance se actualiza via mensajes en el loop de Bubble Tea.
content_hash: c98ec9a38d14fc41a68a44b308986b44022c76cbddd90b08e0e5c5929bdc48d3
code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewProgress crea un modelo de barra de progreso.
  func NewProgress(total float64, label string) ProgressModel {
  	// stub — implementation in devfactory/tui
  	return ProgressModel{}
  }

params_schema:
  {"params":[{"name":"total","desc":"valor total para la barra de progreso"},{"name":"label","desc":"etiqueta descriptiva de la operación"}],"output":"ProgressModel: barra de progreso"}


id: new_progress_with_styles_go_tui
name: new_progress_with_styles
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewProgressWithStyles(total float64, label string, styles Styles) ProgressModel
tags: ["tui","progress","styles","constructor"]
uses_functions: []
uses_types: ["styles_go_tui"]
returns: ["progress_model_go_tui"]
imports: []
example:
  ```go
  styles := DarkStyles()
  model := NewProgressWithStyles(50.0, "Indexando", styles)
  ```

tests: []
file_path: functions/tui/new_progress_with_styles.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Variante de NewProgress que permite personalizar la apariencia de la barra mediante Styles.
content_hash: 61d3a9ff3ef6c0ece4204e0b0b2dd4786772791f9a04d52bae09f7d58cc5b910
description:
  Construye un modelo de barra de progreso con valor total, etiqueta y estilos visuales personalizados.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewProgressWithStyles crea un modelo de barra de progreso con estilos personalizados.
  func NewProgressWithStyles(total float64, label string, styles Styles) ProgressModel {
  	// stub — implementation in devfactory/tui
  	return ProgressModel{}
  }

params_schema:
  {"params":[{"name":"total","desc":"valor total para la barra"},{"name":"label","desc":"etiqueta descriptiva"},{"name":"styles","desc":"Styles para aplicar a la barra"}],"output":"ProgressModel: barra de progreso estilizada"}


id: new_spinner_go_tui
name: new_spinner
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewSpinner(message string) SpinnerModel
description: Construye un modelo de spinner basico con un mensaje descriptivo. Usa el estilo por defecto.
tags: ["tui","spinner","constructor"]
uses_functions: []
uses_types: []
returns: ["spinner_model_go_tui"]
imports: []
example:
  ```go
  model := NewSpinner("Cargando datos...")
  ```

tests: []
file_path: functions/tui/new_spinner.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Spinner con estilos por defecto. Para personalizar apariencia, usar NewSpinnerWithStyle.
content_hash: 739365ccd5816559feb7745490f5f8d37e9085d1a4f226b6d93e2adcf6cf9901
code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewSpinner crea un modelo de spinner con un mensaje.
  func NewSpinner(message string) SpinnerModel {
  	// stub — implementation in devfactory/tui
  	return SpinnerModel{}
  }

params_schema:
  {"params":[{"name":"message","desc":"mensaje a mostrar junto al spinner"}],"output":"SpinnerModel: spinner con estilos por defecto"}


id: new_spinner_with_style_go_tui
name: new_spinner_with_style
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewSpinnerWithStyle(message string, styles Styles) SpinnerModel
description: Construye un modelo de spinner con mensaje y estilos visuales personalizados.
tags: ["tui","spinner","styles","constructor"]
uses_functions: []
uses_types: ["styles_go_tui"]
returns: ["spinner_model_go_tui"]
imports: []
tests: []
file_path: functions/tui/new_spinner_with_style.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Permite controlar colores y bordes del spinner mediante el sistema de estilos del registry.
content_hash: 7bfbfb42c48bf2e86442c40ee73cffde4138dc64dd24df93c97d9433cee809cc
example:
  ```go
  theme := DarkTheme()
  styles := NewStyles(theme)
  model := NewSpinnerWithStyle("Procesando...", styles)
  ```

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewSpinnerWithStyle crea un modelo de spinner con estilos personalizados.
  func NewSpinnerWithStyle(message string, styles Styles) SpinnerModel {
  	// stub — implementation in devfactory/tui
  	return SpinnerModel{}
  }

params_schema:
  {"params":[{"name":"message","desc":"mensaje a mostrar"},{"name":"styles","desc":"Styles para personalizar apariencia"}],"output":"SpinnerModel: spinner estilizado"}


id: new_spinner_with_timeout_go_tui
name: new_spinner_with_timeout
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewSpinnerWithTimeout(message string, timeout time.Duration) SpinnerWithTimeoutModel
tags: ["tui","spinner","timeout","constructor"]
uses_functions: []
uses_types: []
returns: ["spinner_with_timeout_model_go_tui"]
imports: ["time"]
example:
  ```go
  model := NewSpinnerWithTimeout("Esperando respuesta...", 30*time.Second)
  ```

tests: []
file_path: functions/tui/new_spinner_with_timeout.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 71f03e3dac9537b49c56464a99ccf23761bcd7fe5badef287c15ed3cade1652d
description:
  Construye un modelo de spinner con limite de tiempo. Si la operacion excede el timeout, el spinner se detiene automaticamente.

notes:
  El timeout es configuracion pura — el side effect de cancelar ocurre en el runtime de Bubble Tea, no en el constructor.

code:
  package tui

  import "time"

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewSpinnerWithTimeout crea un modelo de spinner que se cancela tras un timeout.
  func NewSpinnerWithTimeout(message string, timeout time.Duration) SpinnerWithTimeoutModel {
  	// stub — implementation in devfactory/tui
  	return SpinnerWithTimeoutModel{}
  }

params_schema:
  {"params":[{"name":"message","desc":"mensaje del spinner"},{"name":"timeout","desc":"duración máxima antes de auto-parada"}],"output":"SpinnerWithTimeoutModel: spinner con límite de tiempo"}


id: new_styles_go_tui
name: new_styles
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewStyles(theme Theme) Styles
tags: ["tui","styles","constructor"]
uses_functions: []
uses_types: ["theme_go_tui"]
returns: ["styles_go_tui"]
imports: []
example:
  ```go
  theme := DefaultTheme()
  styles := NewStyles(theme)
  ```

tests: []
file_path: functions/tui/new_styles.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 126a4c74daa3cde84ea29e494ff68620bf8f5e8e8e1d29e22fc29a50a00087a0
description:
  Construye un conjunto de estilos lipgloss a partir de un tema de colores. Los estilos resultantes se aplican a todos los componentes TUI.

notes:
  Convierte un Theme (paleta de colores) en Styles (estilos lipgloss aplicables). Es el puente entre la configuracion de colores y el renderizado.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewStyles crea estilos a partir de un tema.
  func NewStyles(theme Theme) Styles {
  	// stub — implementation in devfactory/tui
  	return Styles{}
  }

params_schema:
  {"params":[{"name":"theme","desc":"Theme con paleta de colores"}],"output":"Styles: estilos lipgloss aplicables a componentes"}


id: print_error_go_tui
name: print_error
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintError(msg string)
description: Imprime un mensaje con estilo de error (rojo) en stderr.
tags: ["tui","print","error","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintError("No se pudo conectar al servidor")
  ```

tests: []
file_path: functions/tui/print_error.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: cf2ee342f91339636be0f7579373b7935f8a63c32f7e5baffd74f5ccffcb96a7
notes:
  Implementacion real en github.com/lucasdataproyects/devfactory/tui. Este stub existe para documentar la firma y mantener el registry.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintError imprime un mensaje con estilo de error (rojo) en stderr.
  func PrintError(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje de error a imprimir"}],"output":"sin salida; imprime a stderr con estilo rojo"}


id: print_info_go_tui
name: print_info
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintInfo(msg string)
description: Imprime un mensaje con estilo informativo (cyan) en stdout.
tags: ["tui","print","info","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintInfo("Procesando 42 archivos...")
  ```

tests: []
file_path: functions/tui/print_info.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 69a49be5462410a309915fd575809952916888fdd4ec955bae296ec9d2c0a049
notes:
  Implementacion real en github.com/lucasdataproyects/devfactory/tui. Este stub existe para documentar la firma y mantener el registry.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintInfo imprime un mensaje con estilo informativo (cyan) en stdout.
  func PrintInfo(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje informativo a imprimir"}],"output":"sin salida; imprime a stdout con estilo cyan"}


id: print_muted_go_tui
name: print_muted
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintMuted(msg string)
description: Imprime un mensaje con estilo atenuado (gris) en stdout.
tags: ["tui","print","muted","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintMuted("Ultima actualizacion: hace 5 minutos")
  ```

tests: []
file_path: functions/tui/print_muted.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5c829a8809f44c668812e9a77f362f0b55975bbcfe94a2bba7160244e7b8308d
notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintMuted imprime un mensaje con estilo atenuado (gris) en stdout.
  func PrintMuted(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje atenuado a imprimir"}],"output":"sin salida; imprime a stdout con estilo gris"}


id: print_success_go_tui
name: print_success
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintSuccess(msg string)
description: Imprime un mensaje con estilo de exito (verde) en stdout.
tags: ["tui","print","success","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintSuccess("Operacion completada correctamente")
  ```

tests: []
file_path: functions/tui/print_success.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: dbf59cafabb4fdbf0185841363c5b339e78d426b82fa7c04e97200692a1a4e96
notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintSuccess imprime un mensaje con estilo de exito (verde) en stdout.
  func PrintSuccess(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje de éxito a imprimir"}],"output":"sin salida; imprime a stdout con estilo verde"}


id: print_warning_go_tui
name: print_warning
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintWarning(msg string)
description: Imprime un mensaje con estilo de advertencia (naranja) en stdout.
tags: ["tui","print","warning","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintWarning("El archivo sera sobreescrito")
  ```

tests: []
file_path: functions/tui/print_warning.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b6c197a5dc678ec5aa3c325c6f5f10b6f006ebd8120a2975cc532c008266779d
notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintWarning imprime un mensaje con estilo de advertencia (naranja) en stdout.
  func PrintWarning(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje de advertencia a imprimir"}],"output":"sin salida; imprime a stdout con estilo naranja"}


id: quit_msg_go_tui
name: quit_msg
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func Quit() tea.Msg
description: Devuelve un mensaje de salida para el bucle de Bubble Tea.
tags: ["tui","quit","message","bubbletea"]
uses_functions: []
uses_types: []
returns: []
imports: ["github.com/charmbracelet/bubbletea"]
example:
  ```go
  msg := Quit()
  // msg es tea.QuitMsg{}, usado para terminar el event loop de Bubble Tea
  ```

tests: []
file_path: functions/tui/quit_msg.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 0a615073f453fd2b776ce8ce4a7c9fff380830c668e743c63f085d75a8407631
params_schema: {"output":"tea.Msg: mensaje de salida para el event loop"}
notes:
  Wrapper puro sobre tea.QuitMsg{}. Util para componer en pipelines o pasar como callback sin exponer el tipo directamente.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  import tea "github.com/charmbracelet/bubbletea"

  // Quit devuelve un mensaje de salida para el bucle de Bubble Tea.
  func Quit() tea.Msg {
  	return tea.QuitMsg{}
  }


id: run_fullscreen_go_tui
name: run_fullscreen
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func RunFullscreen[T tea.Model](model T) core.Result[T]
description: Ejecuta un modelo Bubble Tea en modo fullscreen.
tags: ["tui","runner","fullscreen","bubbletea","generic"]
uses_functions: []
uses_types: ["result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["github.com/charmbracelet/bubbletea"]
tests: []
file_path: functions/tui/run_fullscreen.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 55752626346e172a73b663cfc4b8ecf19af0d2c457fc7282808a73788ce438ea
example:
  ```go
  result := tui.RunFullscreen(myModel)
  if result.IsErr() {
      log.Fatal(result.Error())
  }
  finalModel := result.Unwrap()
  ```

notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  import tea "github.com/charmbracelet/bubbletea"

  // RunFullscreen ejecuta un modelo Bubbletea en modo fullscreen.
  func RunFullscreen[T tea.Model](model T) (T, error) {
  	// stub — implementation in devfactory/tui
  	var zero T
  	return zero, nil
  }

params_schema:
  {"params":[{"name":"model","desc":"modelo Bubble Tea a ejecutar"}],"output":"Result[T]: modelo final o error"}


id: run_model_go_tui
name: run_model
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func RunModel[T tea.Model](model T) core.Result[T]
description: Ejecuta un modelo Bubble Tea y devuelve el modelo final o error.
tags: ["tui","runner","bubbletea","generic"]
uses_functions: []
uses_types: ["result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["github.com/charmbracelet/bubbletea"]
tests: []
file_path: functions/tui/run_model.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 8c5513003396d78c3c69865a05546414a2e4cb1086e3d2d6baf635b51c8e1e2c
example:
  ```go
  result := tui.RunModel(myModel)
  if result.IsErr() {
      log.Fatal(result.Error())
  }
  finalModel := result.Unwrap()
  ```

notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  import tea "github.com/charmbracelet/bubbletea"

  // RunModel ejecuta un modelo Bubbletea y devuelve el modelo final.
  func RunModel[T tea.Model](model T) (T, error) {
  	// stub — implementation in devfactory/tui
  	var zero T
  	return zero, nil
  }

params_schema:
  {"params":[{"name":"model","desc":"modelo Bubble Tea a ejecutar"}],"output":"Result[T]: modelo final o error"}


id: run_with_mouse_go_tui
name: run_with_mouse
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func RunWithMouseSupport[T tea.Model](model T) core.Result[T]
description: Ejecuta un modelo Bubble Tea con soporte de raton habilitado.
tags: ["tui","runner","mouse","bubbletea","generic"]
uses_functions: []
uses_types: ["result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["github.com/charmbracelet/bubbletea"]
tests: []
file_path: functions/tui/run_with_mouse.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 415a6b1ff0d325d05c5706674ee74f093642cf5e096875606002a85b93c0dda3
example:
  ```go
  result := tui.RunWithMouseSupport(myModel)
  if result.IsErr() {
      log.Fatal(result.Error())
  }
  finalModel := result.Unwrap()
  ```

notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  import tea "github.com/charmbracelet/bubbletea"

  // RunWithMouseSupport ejecuta un modelo Bubbletea con soporte de mouse.
  func RunWithMouseSupport[T tea.Model](model T) (T, error) {
  	// stub — implementation in devfactory/tui
  	var zero T
  	return zero, nil
  }

params_schema:
  {"params":[{"name":"model","desc":"modelo Bubble Tea a ejecutar con soporte de ratón"}],"output":"Result[T]: modelo final o error"}


id: show_cursor_go_tui
name: show_cursor
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func ShowCursor() string
description: Devuelve el codigo de escape ANSI para mostrar el cursor del terminal.
tags: ["tui","ansi","terminal","cursor"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```go
  fmt.Print(ShowCursor())
  // Restaura la visibilidad del cursor del terminal
  ```

tests: []
file_path: functions/tui/show_cursor.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 30bc8ea3a756a2b14b276efca20b8988826002c489745685cddba3504417c953
params_schema: {"output":"string: código ANSI para mostrar el cursor"}
notes:
  Usa la secuencia DECTCEM (ESC[?25h) del estandar VT220. Complemento de HideCursor. Llamar siempre al finalizar la aplicacion para restaurar el estado del terminal.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // ShowCursor devuelve el codigo de escape ANSI para mostrar el cursor del terminal.
  func ShowCursor() string {
  	return "\033[?25h"
  }


id: assert_docker_container_running_bash_infra
name: assert_docker_container_running
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: assert_docker_container_running(container_name: string) -> void
tags: ["assert","docker","container","running","validation","infra","bash"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/assert_docker_container_running.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 145d610fbb3cbe56b2beee02a4de3ff4d79a866082f84bf94d03c6b571681d97
description:
  Verifica que un contenedor Docker está corriendo. Sale con exit code 1 si no está activo, con mensaje a stderr.

example:
  ```bash
  source functions/infra/assert_docker_container_running.sh

  assert_docker_container_running metabase
  echo "Contenedor activo, continuando..."
  ```

notes:
  Usa `docker ps --format '{{.Names}}'` con grep anclado (`^name$`) para evitar matches parciales (ej: "metabase" no matchea "metabase-test").

  Output limpio: void en éxito. El mensaje de error en stderr no incluye lista de contenedores activos — eso es responsabilidad del pipeline/caller.

  Requiere que `docker` esté en PATH. Combinar con `assert_command_exists` antes de llamar.

code:
  # assert_docker_container_running
  # --------------------------------
  # Verifica que un contenedor Docker está corriendo.
  # No produce output a stdout en caso de éxito.
  # Sale con exit code 1 si el contenedor no está corriendo,
  # con mensaje descriptivo a stderr.
  #
  # USO (sourced):
  #   source assert_docker_container_running.sh
  #   assert_docker_container_running metabase

  assert_docker_container_running() {
      local container_name="$1"

      if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
          echo "assert_docker_container_running: el contenedor '$container_name' no está corriendo" >&2
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"container_name","desc":"nombre del contenedor Docker a verificar"}],"output":"sin salida; exit code 0 si existe y está corriendo, 1 si no"}


id: build_cpp_linux_bash_infra
name: build_cpp_linux
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: build_cpp_linux(target?: string) -> void
description: Compila las funciones y apps C++ del registry para Linux nativo usando cmake
tags: ["cpp","build","cmake","linux","imgui"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/build_cpp_linux.sh
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
variant: []
content_hash: 7aad296717375fac47ac533cbf3d258df7104d27a04da06322b871d5adaf9c82
documentation:
  # build_cpp_linux

  Configura y compila el proyecto C++ (ImGui/ImPlot) para Linux nativo.

  Usa cmake con compilacion paralela (`-j$(nproc)`). Si no se ha configurado antes, ejecuta `cmake -B` automaticamente.

  ```bash

  fn run build_cpp_linux              # Compilar todo

  fn run build_cpp_linux chart_demo   # Compilar solo chart_demo

  ```

code:
  #!/usr/bin/env bash
  set -euo pipefail

  REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
  CPP_ROOT="$REGISTRY_ROOT/cpp"
  BUILD_DIR="$CPP_ROOT/build/linux"
  TARGET="${1:-}"

  # Configure if needed
  if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
      echo "[build_cpp_linux] Configuring cmake..."
      cmake -B "$BUILD_DIR" -S "$CPP_ROOT"
  fi

  # Build
  if [ -n "$TARGET" ]; then
      echo "[build_cpp_linux] Building target: $TARGET"
      cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
  else
      echo "[build_cpp_linux] Building all targets..."
      cmake --build "$BUILD_DIR" -- -j"$(nproc)"
  fi

  echo "[build_cpp_linux] Done. Binaries in $BUILD_DIR"

params_schema:
  {"params":[{"name":"target","desc":"Nombre del target cmake a compilar (opcional, sin argumento compila todo)"}],"output":"Compila los binarios en cpp/build/linux/"}


id: build_cpp_windows_bash_infra
name: build_cpp_windows
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: build_cpp_windows(target?: string) -> void
description: Cross-compila las funciones y apps C++ del registry para Windows usando mingw-w64
tags: ["cpp","build","cmake","windows","cross-compile","mingw","imgui"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/build_cpp_windows.sh
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
variant: []
content_hash: ee946317d4f02273b0c7e28b686e422c24b8e197d91895a07e6407a47f7cb95d
documentation:
  # build_cpp_windows

  Cross-compila el proyecto C++ para Windows desde Linux usando el toolchain mingw-w64.

  Los .exe resultantes incluyen runtime linkado estaticamente (self-contained).

  ```bash

  fn run build_cpp_windows              # Compilar todo

  fn run build_cpp_windows chart_demo   # Compilar solo chart_demo

  ```

  Requiere `mingw-w64`: `sudo apt install mingw-w64`

code:
  #!/usr/bin/env bash
  set -euo pipefail

  REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
  CPP_ROOT="$REGISTRY_ROOT/cpp"
  BUILD_DIR="$CPP_ROOT/build/windows"
  TOOLCHAIN="$CPP_ROOT/toolchains/mingw-w64.cmake"
  TARGET="${1:-}"

  # Check mingw is available
  if ! command -v x86_64-w64-mingw32-g++ &>/dev/null; then
      echo "[build_cpp_windows] Error: mingw-w64 not found. Install with: sudo apt install mingw-w64"
      exit 1
  fi

  # Configure if needed
  if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
      echo "[build_cpp_windows] Configuring cmake with mingw-w64 toolchain..."
      cmake -B "$BUILD_DIR" -S "$CPP_ROOT" -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN"
  fi

  # Build
  if [ -n "$TARGET" ]; then
      echo "[build_cpp_windows] Cross-compiling target: $TARGET"
      cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
  else
      echo "[build_cpp_windows] Cross-compiling all targets..."
      cmake --build "$BUILD_DIR" -- -j"$(nproc)"
  fi

  echo "[build_cpp_windows] Done. Windows binaries in $BUILD_DIR"
  if [ -n "$TARGET" ]; then
      file "$BUILD_DIR"/**/"$TARGET".exe 2>/dev/null || file "$BUILD_DIR/$TARGET".exe 2>/dev/null || true
  fi

params_schema:
  {"params":[{"name":"target","desc":"Nombre del target cmake a compilar (opcional, sin argumento compila todo)"}],"output":"Produce binarios .exe de Windows en cpp/build/windows/"}


id: docker_cp_file_bash_infra
name: docker_cp_file
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: docker_cp_file(local_path: string, container_name: string, dest_path: string) -> string
tags: ["docker","cp","copy","file","container","transfer","infra","bash"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/docker_cp_file.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 34cf8d182c9154609e1f09f14513194ccc85fc91b55b155bf06f0bd6ffe90528
description:
  Copia un archivo local a un contenedor Docker y verifica que el tamaño coincide. Imprime JSON con local_size y remote_size a stdout. Sale con exit code 1 si docker cp falla o los tamaños difieren.

example:
  ```bash
  source functions/infra/docker_cp_file.sh

  result=$(docker_cp_file /home/lucas/fn_registry/registry.db metabase /registry.db)
  echo "$result"
  # {"local_size":524288,"remote_size":524288}

  local_size=$(echo "$result" | grep -o '"local_size":[0-9]*' | cut -d: -f2)
  ```

notes:
  La verificación de tamaño usa `docker exec stat -c%s` sobre el contenedor destino. Si `stat` no está disponible en el contenedor, `remote_size` será -1 y la función fallará.

  Output a stdout: JSON minificado con campos `local_size` y `remote_size` (enteros, bytes).

  Usa `printf` en lugar de `echo` para garantizar que no haya newline extra en el JSON.

code:
  # docker_cp_file
  # --------------
  # Copia un archivo local a un contenedor Docker y verifica que el tamaño coincide.
  # Imprime JSON con local_size y remote_size a stdout si la copia es exitosa.
  # Sale con exit code 1 si docker cp falla o si los tamaños no coinciden.
  #
  # USO (sourced):
  #   source docker_cp_file.sh
  #   result=$(docker_cp_file /ruta/local.db metabase /dest/path.db)

  docker_cp_file() {
      local local_path="$1"
      local container_name="$2"
      local dest_path="$3"

      if ! docker cp "$local_path" "${container_name}:${dest_path}" 2>/dev/null; then
          echo "docker_cp_file: fallo al copiar '$local_path' a '${container_name}:${dest_path}'" >&2
          return 1
      fi

      local local_size
      local_size=$(stat -c%s "$local_path")

      local remote_size
      remote_size=$(docker exec "$container_name" stat -c%s "$dest_path" 2>/dev/null || echo "-1")

      if [ "$local_size" != "$remote_size" ]; then
          echo "docker_cp_file: tamaños no coinciden (local=${local_size}, remoto=${remote_size})" >&2
          return 1
      fi

      printf '{"local_size":%s,"remote_size":%s}' "$local_size" "$remote_size"
  }

params_schema:
  {"params":[{"name":"local_path","desc":"ruta del archivo local a copiar"},{"name":"container_name","desc":"nombre del contenedor Docker destino"},{"name":"dest_path","desc":"ruta destino dentro del contenedor"}],"output":"JSON con local_size y remote_size en bytes"}


id: frontend_doctor_bash_infra
name: frontend_doctor
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: frontend_doctor(project_dir: string) -> diagnostics_stdout
tags: ["frontend","mantine","doctor","diagnostics","health","validation"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/frontend_doctor.sh
created_at: 2026-04-05T23:51:12Z
updated_at: 2026-04-05T23:51:12Z
props: []
emits: []
variant: []
content_hash: eb61d0f0248ba6d23bbde74add06154c387aed91578408f487ae46479474794f
description:
  Diagnostica la salud de un proyecto frontend Mantine. Verifica Node, React, Mantine, PostCSS, TypeScript, vite.config y detecta residuos de shadcn/@base-ui. Imprime tabla de checks con exit code 0/1.

example:
  ```bash
  # Diagnosticar un proyecto
  bash frontend_doctor.sh ./apps/rapid_dashboards/frontend

  # Output:
  # === Frontend Doctor: ./apps/rapid_dashboards/frontend ===
  #
  #   ✓  Node >= 18                          22.12.0
  #   ✓  Package manager detected            pnpm
  #   ✓  node_modules present
  #   ✓  @mantine/core                       7.17.0
  #   ✓  @mantine/hooks
  #   ✓  @mantine/charts
  #   ✓  React >= 18                         19.2.4
  #   ✓  postcss.config present
  #   ✓  TypeScript >= 5                     6.0.2
  #   ✓  vite.config present
  #   ✓  No shadcn residual
  #   ✓  No @base-ui residual
  #
  #   Resultado: todo OK
  ```

notes:
  Checks informativos, no modifica nada. Util para validar que un proyecto esta correctamente configurado despues de instalar Mantine o migrar desde shadcn. Exit code 0 si todo OK, 1 si hay problemas.

code:
  # frontend_doctor
  # ----------------
  # Diagnostica la salud de un proyecto frontend Mantine.
  # Verifica dependencias, configuracion y versiones.
  # Imprime tabla de checks y retorna exit code 0 (ok) o 1 (fallos).
  #
  # USO (sourced):
  #   source frontend_doctor.sh
  #   frontend_doctor /path/to/frontend
  #
  # USO (directo):
  #   bash frontend_doctor.sh /path/to/frontend

  frontend_doctor() {
      local project_dir="$1"
      local failures=0

      if [ -z "$project_dir" ]; then
          echo "frontend_doctor: se requiere project_dir" >&2
          return 1
      fi

      if [ ! -f "$project_dir/package.json" ]; then
          echo "frontend_doctor: no existe package.json en $project_dir" >&2
          return 1
      fi

      echo "=== Frontend Doctor: $project_dir ==="
      echo ""

      # Helper: check y reportar
      _check() {
          local label="$1"
          local ok="$2"
          local detail="$3"
          if [ "$ok" = "1" ]; then
              printf "  ✓  %-35s %s\n" "$label" "$detail"
          else
              printf "  ✗  %-35s %s\n" "$label" "$detail"
              ((failures++))
          fi
      }

      # 1. Node >= 18
      local node_ver=""
      local node_ok=0
      if command -v node &>/dev/null; then
          node_ver=$(node -v 2>/dev/null | sed 's/v//')
          local node_major=$(echo "$node_ver" | cut -d. -f1)
          [ "$node_major" -ge 18 ] 2>/dev/null && node_ok=1
      fi
      _check "Node >= 18" "$node_ok" "${node_ver:-not found}"

      # 2. Package manager
      local pm_ok=0
      local pm_name="none"
      if [ -f "$project_dir/pnpm-lock.yaml" ]; then
          pm_name="pnpm"; pm_ok=1
      elif [ -f "$project_dir/yarn.lock" ]; then
          pm_name="yarn"; pm_ok=1
      elif [ -f "$project_dir/package-lock.json" ]; then
          pm_name="npm"; pm_ok=1
      fi
      _check "Package manager detected" "$pm_ok" "$pm_name"

      # 3. node_modules existe
      local nm_ok=0
      [ -d "$project_dir/node_modules" ] && nm_ok=1
      _check "node_modules present" "$nm_ok" ""

      # 4. @mantine/core instalado
      local mantine_ok=0
      local mantine_ver=""
      if [ -f "$project_dir/node_modules/@mantine/core/package.json" ]; then
          mantine_ver=$(node -e "console.log(require('$project_dir/node_modules/@mantine/core/package.json').version)" 2>/dev/null)
          mantine_ok=1
      fi
      _check "@mantine/core" "$mantine_ok" "${mantine_ver:-not installed}"

      # 5. @mantine/hooks
      local hooks_ok=0
      [ -d "$project_dir/node_modules/@mantine/hooks" ] && hooks_ok=1
      _check "@mantine/hooks" "$hooks_ok" ""

      # 6. @mantine/charts
      local charts_ok=0
      [ -d "$project_dir/node_modules/@mantine/charts" ] && charts_ok=1
      _check "@mantine/charts" "$charts_ok" ""

      # 7. React >= 18
      local react_ok=0
      local react_ver=""
      if [ -f "$project_dir/node_modules/react/package.json" ]; then
          react_ver=$(node -e "console.log(require('$project_dir/node_modules/react/package.json').version)" 2>/dev/null)
          local react_major=$(echo "$react_ver" | cut -d. -f1)
          [ "$react_major" -ge 18 ] 2>/dev/null && react_ok=1
      fi
      _check "React >= 18" "$react_ok" "${react_ver:-not found}"

      # 8. postcss.config presente
      local postcss_ok=0
      if [ -f "$project_dir/postcss.config.cjs" ] || [ -f "$project_dir/postcss.config.js" ] || [ -f "$project_dir/postcss.config.mjs" ]; then
          postcss_ok=1
      fi
      _check "postcss.config present" "$postcss_ok" ""

      # 9. TypeScript >= 5
      local ts_ok=0
      local ts_ver=""
      if [ -f "$project_dir/node_modules/typescript/package.json" ]; then
          ts_ver=$(node -e "console.log(require('$project_dir/node_modules/typescript/package.json').version)" 2>/dev/null)
          local ts_major=$(echo "$ts_ver" | cut -d. -f1)
          [ "$ts_major" -ge 5 ] 2>/dev/null && ts_ok=1
      fi
      _check "TypeScript >= 5" "$ts_ok" "${ts_ver:-not found}"

      # 10. vite.config presente
      local vite_ok=0
      if [ -f "$project_dir/vite.config.ts" ] || [ -f "$project_dir/vite.config.js" ]; then
          vite_ok=1
      fi
      _check "vite.config present" "$vite_ok" ""

      # 11. Shadcn residual (warning)
      local shadcn_clean=1
      if [ -f "$project_dir/components.json" ]; then
          shadcn_clean=0
      fi
      _check "No shadcn residual" "$shadcn_clean" "$([ "$shadcn_clean" = "0" ] && echo 'components.json found')"

      # 12. @base-ui residual (warning)
      local baseui_clean=1
      if [ -d "$project_dir/node_modules/@base-ui" ]; then
          baseui_clean=0
      fi
      _check "No @base-ui residual" "$baseui_clean" "$([ "$baseui_clean" = "0" ] && echo '@base-ui still installed')"

      echo ""
      if [ "$failures" -eq 0 ]; then
          echo "  Resultado: todo OK"
          return 0
      else
          echo "  Resultado: $failures problema(s) encontrado(s)"
          return 1
      fi
  }

  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      frontend_doctor "$@"
  fi

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto frontend con package.json"}],"output":"tabla de checks con ✓/✗ por cada validación y resumen final"}


id: gitea_add_collaborator_bash_infra
name: gitea_add_collaborator
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_add_collaborator(owner: string, repo: string, username: string, permission: string) -> void
tags: ["gitea","git","collaborator","permission","repo","api","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_add_collaborator.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: 26ad480468b3d06fc2b01b02a9c287654037e07b880cf98c216235437a081bf2
description:
  Añade un colaborador a un repositorio Gitea con el nivel de permisos indicado. Silencioso si el colaborador ya existe (422).

example:
  ```bash
  source bash/functions/infra/gitea_add_collaborator.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Añadir colaborador con permiso admin (default)
  gitea_add_collaborator "myorg" "my-app" "egutierrez"

  # Añadir colaborador con permiso de solo lectura
  gitea_add_collaborator "myorg" "my-app" "reviewer" "read"
  ```

notes:
  - Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
  - Un 422 de la API indica que el usuario ya es colaborador — se trata como éxito silencioso.
  - La función no produce salida a stdout; los mensajes informativos van a stderr.
  - Nivel `admin` da acceso completo al repo incluyendo settings.

code:
  #!/usr/bin/env bash
  # gitea_add_collaborator — Añade un colaborador a un repositorio Gitea

  gitea_add_collaborator() {
      local owner="$1"
      local repo="$2"
      local username="$3"
      local permission="${4:-admin}"

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_add_collaborator: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_add_collaborator: GITEA_TOKEN no está seteado" >&2
          return 1
      fi
      if [[ -z "$owner" || -z "$repo" || -z "$username" ]]; then
          echo "gitea_add_collaborator: se requieren owner, repo y username" >&2
          return 1
      fi

      local payload
      payload=$(printf '{"permission":"%s"}' "$permission")

      echo "gitea_add_collaborator: añadiendo '$username' a '$owner/$repo' con permiso '$permission'..." >&2

      local response http_code
      response=$(curl -s -w "\n%{http_code}" \
          -X PUT \
          -H "Content-Type: application/json" \
          -H "Authorization: token ${GITEA_TOKEN}" \
          -d "$payload" \
          "${GITEA_URL}/api/v1/repos/${owner}/${repo}/collaborators/${username}")

      http_code=$(echo "$response" | tail -n1)
      local body
      body=$(echo "$response" | head -n -1)

      if [[ "$http_code" == "204" || "$http_code" == "200" ]]; then
          echo "gitea_add_collaborator: '$username' añadido a '$owner/$repo'" >&2
          return 0
      fi

      if [[ "$http_code" == "422" ]]; then
          echo "gitea_add_collaborator: '$username' ya es colaborador de '$owner/$repo' (silencioso)" >&2
          return 0
      fi

      echo "gitea_add_collaborator: error (HTTP ${http_code}): ${body}" >&2
      return 1
  }

params_schema:
  {"params":[{"name":"owner","desc":"usuario u organización propietaria del repositorio"},{"name":"repo","desc":"nombre del repositorio"},{"name":"username","desc":"nombre de usuario del colaborador a añadir"},{"name":"permission","desc":"nivel de permisos: 'read', 'write' o 'admin' (default: admin)"}],"output":"vacío — efectos observables a través de la API de Gitea"}


id: gitea_create_repo_bash_infra
name: gitea_create_repo
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_create_repo(owner: string, name: string, private: string, description: string) -> string
tags: ["gitea","git","repo","create","infra","api"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_create_repo.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: e321b9937e6be33965e10b271b2f1a9b834d10112a1312af00cb6df272a8938d
description:
  Crea un repositorio en Gitea para un owner. Intenta crearlo en org primero; si el owner no es una org (404/422), lo crea en el usuario autenticado. No falla fatalmente si el repo ya existe (409).

example:
  ```bash
  source bash/functions/infra/gitea_create_repo.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Crear repo público en org o usuario
  repo_json=$(gitea_create_repo "myorg" "my-app")

  # Crear repo privado con descripción
  repo_json=$(gitea_create_repo "myorg" "my-app" "true" "Mi aplicación principal")

  # Extraer la URL del clon
  clone_url=$(echo "$repo_json" | jq -r '.clone_url')
  ```

notes:
  - Requiere variables de entorno `GITEA_URL` y `GITEA_TOKEN` seteadas antes de invocar.
  - El fallback org → usuario ocurre con HTTP 404 o 422 en el endpoint de orgs.
  - Un 409 se reporta a stderr pero la función retorna 0 — el repo ya existe es una condición aceptable para idempotencia.
  - Los mensajes informativos van a stderr; el JSON de respuesta va a stdout.

code:
  #!/usr/bin/env bash
  # gitea_create_repo — Crea un repositorio en Gitea para un owner (org o usuario)

  gitea_create_repo() {
      local owner="$1"
      local name="$2"
      local private="${3:-false}"
      local description="${4:-}"

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_create_repo: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_create_repo: GITEA_TOKEN no está seteado" >&2
          return 1
      fi
      if [[ -z "$owner" || -z "$name" ]]; then
          echo "gitea_create_repo: se requieren owner y name" >&2
          return 1
      fi

      local payload
      payload=$(printf '{"name":"%s","private":%s,"description":"%s","auto_init":false}' \
          "$name" "$private" "$description")

      echo "gitea_create_repo: intentando crear '$owner/$name' en org..." >&2

      local response http_code
      response=$(curl -s -w "\n%{http_code}" \
          -X POST \
          -H "Content-Type: application/json" \
          -H "Authorization: token ${GITEA_TOKEN}" \
          -d "$payload" \
          "${GITEA_URL}/api/v1/orgs/${owner}/repos")

      http_code=$(echo "$response" | tail -n1)
      local body
      body=$(echo "$response" | head -n -1)

      if [[ "$http_code" == "201" ]]; then
          echo "gitea_create_repo: repo '$owner/$name' creado en org" >&2
          echo "$body"
          return 0
      fi

      if [[ "$http_code" == "409" ]]; then
          echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
          echo "$body"
          return 0
      fi

      if [[ "$http_code" == "404" || "$http_code" == "422" ]]; then
          echo "gitea_create_repo: org no encontrada (${http_code}), intentando en usuario..." >&2
          response=$(curl -s -w "\n%{http_code}" \
              -X POST \
              -H "Content-Type: application/json" \
              -H "Authorization: token ${GITEA_TOKEN}" \
              -d "$payload" \
              "${GITEA_URL}/api/v1/user/repos")

          http_code=$(echo "$response" | tail -n1)
          body=$(echo "$response" | head -n -1)

          if [[ "$http_code" == "201" ]]; then
              echo "gitea_create_repo: repo '$owner/$name' creado en usuario" >&2
              echo "$body"
              return 0
          fi

          if [[ "$http_code" == "409" ]]; then
              echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
              echo "$body"
              return 0
          fi

          echo "gitea_create_repo: error al crear en usuario (HTTP ${http_code}): ${body}" >&2
          return 1
      fi

      echo "gitea_create_repo: error inesperado (HTTP ${http_code}): ${body}" >&2
      return 1
  }

params_schema:
  {"params":[{"name":"owner","desc":"usuario u organización propietaria del repo"},{"name":"name","desc":"nombre del repositorio a crear"},{"name":"private","desc":"si el repo es privado, 'true' o 'false' (default: false)"},{"name":"description","desc":"descripción del repositorio (opcional)"}],"output":"JSON del repositorio creado según la API de Gitea"}


id: gitea_list_repos_bash_infra
name: gitea_list_repos
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_list_repos(owner: string) -> string
tags: ["gitea","git","repo","list","org","user","api","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_list_repos.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: a8464110043a3a2761afa00e41da77627c4a5adb949259165ecb89da9303e428
description:
  Lista repositorios de un owner en Gitea. Intenta listar como org primero; si falla, lista como usuario. Imprime una línea por repo en formato name<TAB>html_url<TAB>description.

example:
  ```bash
  source bash/functions/infra/gitea_list_repos.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Listar todos los repos de una org
  gitea_list_repos "myorg"
  # my-app    https://git.example.com/myorg/my-app    Mi aplicación principal
  # infra     https://git.example.com/myorg/infra

  # Iterar sobre los repos
  while IFS=$'\t' read -r name url desc; do
      echo "Repo: $name — $url"
  done < <(gitea_list_repos "myorg")
  ```

notes:
  - Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
  - Usa `jq` si está disponible; fallback con grep/sed en caso contrario.
  - El límite es 50 repos por página. Para owners con más de 50 repos habría que implementar paginación.
  - Los mensajes informativos van a stderr; los datos tabulados van a stdout.

code:
  #!/usr/bin/env bash
  # gitea_list_repos — Lista repositorios de un owner (org o usuario) en Gitea

  gitea_list_repos() {
      local owner="$1"

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_list_repos: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_list_repos: GITEA_TOKEN no está seteado" >&2
          return 1
      fi
      if [[ -z "$owner" ]]; then
          echo "gitea_list_repos: se requiere owner" >&2
          return 1
      fi

      echo "gitea_list_repos: listando repos de '$owner' (intentando org)..." >&2

      local response http_code body
      response=$(curl -s -w "\n%{http_code}" \
          -H "Authorization: token ${GITEA_TOKEN}" \
          "${GITEA_URL}/api/v1/orgs/${owner}/repos?limit=50")

      http_code=$(echo "$response" | tail -n1)
      body=$(echo "$response" | head -n -1)

      if [[ "$http_code" != "200" ]]; then
          echo "gitea_list_repos: org no encontrada (${http_code}), intentando usuario..." >&2
          response=$(curl -s -w "\n%{http_code}" \
              -H "Authorization: token ${GITEA_TOKEN}" \
              "${GITEA_URL}/api/v1/users/${owner}/repos?limit=50")

          http_code=$(echo "$response" | tail -n1)
          body=$(echo "$response" | head -n -1)

          if [[ "$http_code" != "200" ]]; then
              echo "gitea_list_repos: error listando repos de usuario (HTTP ${http_code}): ${body}" >&2
              return 1
          fi
      fi

      # Formatear salida como name\thtml_url\tdescription
      if command -v jq &>/dev/null; then
          echo "$body" | jq -r '.[] | [.name, .html_url, (.description // "")] | @tsv'
      else
          # Fallback sin jq: extraer campos básicos con grep/sed
          echo "$body" | grep -o '"name":"[^"]*"\|"html_url":"[^"]*"\|"description":"[^"]*"' \
              | paste - - - | sed 's/"name":"//;s/"html_url":"//;s/"description":"//;s/"//g'
      fi
  }

params_schema:
  {"params":[{"name":"owner","desc":"nombre del usuario u organización cuyos repos se listan"}],"output":"una línea por repositorio con columnas separadas por tabulador: name, html_url, description"}


id: gitea_push_directory_bash_infra
name: gitea_push_directory
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_push_directory(directory: string, owner: string, repo: string, branch: string) -> void
tags: ["gitea","git","push","directory","sync","infra","repo"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_push_directory.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: 3f0868b69167a6bac201dd5ba6e19b94461be9e510d14d55f629fc778c0cfde3
description:
  Inicializa git en un directorio local y lo sube a un repositorio Gitea existente. Si el directorio ya tiene .git, actualiza el remote y pushea cambios pendientes. Protege registry.db añadiéndolo al .gitignore antes del commit.

example:
  ```bash
  source bash/functions/infra/gitea_push_directory.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Subir directorio a repo existente
  gitea_push_directory "/home/lucas/myproject" "myorg" "my-app"

  # Subir a rama específica
  gitea_push_directory "/home/lucas/myproject" "myorg" "my-app" "develop"
  ```

notes:
  - Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
  - El token se embebe en la URL del remote para autenticación (nunca se imprime a stdout/stderr, se enmascara con ***).
  - Si `registry.db` existe en el directorio, se añade automáticamente al `.gitignore` local.
  - Si el `.git` ya existe con un remote diferente, se redirige al repo indicado sin perder el historial local.
  - Usa `--force-with-lease` para el primer push y fallback a push normal (para repos vacíos recién creados).
  - El commit se firma con `agent@fn-registry` si no hay configuración git en el entorno.

code:
  #!/usr/bin/env bash
  # gitea_push_directory — Inicializa git en un directorio y lo sube a un repo Gitea existente

  gitea_push_directory() {
      local directory="$1"
      local owner="$2"
      local repo="$3"
      local branch="${4:-main}"

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_push_directory: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_push_directory: GITEA_TOKEN no está seteado" >&2
          return 1
      fi
      if [[ -z "$directory" || -z "$owner" || -z "$repo" ]]; then
          echo "gitea_push_directory: se requieren directory, owner y repo" >&2
          return 1
      fi
      if [[ ! -d "$directory" ]]; then
          echo "gitea_push_directory: directorio '$directory' no existe" >&2
          return 1
      fi

      # Construir URL con credenciales embebidas para autenticación
      local gitea_host
      gitea_host=$(echo "$GITEA_URL" | sed 's|https\?://||')
      local remote_url="https://${GITEA_TOKEN}@${gitea_host}/${owner}/${repo}.git"
      local display_url="https://***@${gitea_host}/${owner}/${repo}.git"

      echo "gitea_push_directory: procesando '$directory' → '$owner/$repo' (rama: $branch)..." >&2

      # Añadir registry.db al .gitignore local si existe en el directorio
      if [[ -f "$directory/registry.db" ]]; then
          echo "gitea_push_directory: añadiendo registry.db al .gitignore..." >&2
          if [[ ! -f "$directory/.gitignore" ]] || ! grep -qxF "registry.db" "$directory/.gitignore"; then
              echo "registry.db" >> "$directory/.gitignore"
          fi
      fi

      # Gestionar estado del repositorio git
      if [[ -d "$directory/.git" ]]; then
          local existing_remote
          existing_remote=$(git -C "$directory" remote get-url origin 2>/dev/null || echo "")

          if [[ -z "$existing_remote" ]]; then
              echo "gitea_push_directory: añadiendo remote origin..." >&2
              git -C "$directory" remote add origin "$remote_url"
          else
              # Comparar remote sin token para detectar si apunta al mismo repo
              local clean_existing
              clean_existing=$(echo "$existing_remote" | sed 's|https://[^@]*@||;s|https://||')
              local clean_target="${gitea_host}/${owner}/${repo}.git"

              if [[ "$clean_existing" != "$clean_target" ]]; then
                  echo "gitea_push_directory: remote apunta a otro destino ('$clean_existing'), actualizando..." >&2
                  git -C "$directory" remote set-url origin "$remote_url"
              else
                  echo "gitea_push_directory: remote ya apunta al destino correcto, actualizando token..." >&2
                  git -C "$directory" remote set-url origin "$remote_url"
              fi
          fi
      else
          echo "gitea_push_directory: inicializando nuevo repositorio git..." >&2
          git -C "$directory" init
          git -C "$directory" remote add origin "$remote_url"
      fi

      # Configurar rama por defecto
      git -C "$directory" checkout -B "$branch" 2>/dev/null || true

      # Añadir y commitear cambios si los hay
      git -C "$directory" add -A

      local status
      status=$(git -C "$directory" status --porcelain)

      if [[ -n "$status" ]]; then
          echo "gitea_push_directory: commiteando cambios..." >&2
          git -C "$directory" -c user.email="agent@fn-registry" -c user.name="fn-registry agent" \
              commit -m "chore: sync from fn-registry agent"
      else
          echo "gitea_push_directory: sin cambios pendientes, solo haciendo push..." >&2
      fi

      echo "gitea_push_directory: haciendo push a $display_url..." >&2
      git -C "$directory" push --set-upstream origin "$branch" --force-with-lease 2>&1 \
          | sed "s|${GITEA_TOKEN}|***|g" >&2 \
          || git -C "$directory" push --set-upstream origin "$branch" 2>&1 \
              | sed "s|${GITEA_TOKEN}|***|g" >&2

      echo "gitea_push_directory: push completado a '$owner/$repo' rama '$branch'" >&2
  }

params_schema:
  {"params":[{"name":"directory","desc":"ruta absoluta o relativa al directorio local a subir"},{"name":"owner","desc":"usuario u organización propietaria del repositorio Gitea destino"},{"name":"repo","desc":"nombre del repositorio Gitea destino (debe existir previamente)"},{"name":"branch","desc":"rama en la que hacer push (default: main)"}],"output":"vacío — efectos observables en el repositorio Gitea destino"}


id: init_uv_venv_bash_infra
name: init_uv_venv
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: init_uv_venv([project_dir: string]) -> string
tags: ["python","venv","uv","setup","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/init_uv_venv.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: f0316721482ff0715ed3b38820d44506b9124ab7dbecc48b878802fe579c3dad
description:
  Crea un virtualenv Python con uv en el directorio dado si no existe. Fallback a python3 -m venv. Retorna la ruta del venv.

example:
  ```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 .)
  ```

notes:
  Idempotente: si el venv ya existe con un python valido, retorna la ruta sin hacer nada. Prefiere uv por velocidad, usa python3 como fallback.

code:
  # 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"
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto donde crear el venv (default: directorio actual)"}],"output":"ruta absoluta del venv creado o existente"}


id: install_android_sdk_bash_infra
name: install_android_sdk
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_android_sdk() -> void
tags: ["android","sdk","jdk","java","install","infra","mobile"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_android_sdk.sh
created_at: 2026-04-06T16:40:15Z
updated_at: 2026-04-06T16:40:15Z
props: []
emits: []
variant: []
content_hash: 93656abf9e21932065937e90abf5816f1a8830a61018b6276952c7e3101d48a7
params_schema: {"output":"sin salida estructurada; imprime progreso y resumen final con rutas de instalacion"}
description:
  Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source.

example:
  ```bash
  # Instalacion en directorio por defecto ($HOME/android-sdk)
  source install_android_sdk.sh

  # Instalacion en directorio personalizado
  ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh

  # Si ya esta instalado:
  # Android SDK ya instalado en: /home/user/android-sdk

  # Instalacion completa imprime:
  # Descargando JDK 17...
  # JDK 17 instalado: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
  # Descargando Android cmdline-tools...
  # cmdline-tools instalados
  # Aceptando licencias de Android SDK...
  # Instalando platform-tools, platforms;android-34, build-tools;34.0.0...
  #
  # Android SDK instalado en: /home/user/android-sdk
  # JDK 17: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
  # Para activar: source /home/user/android-sdk/env.sh

  # Activar entorno en sesion actual
  source ~/android-sdk/env.sh
  ```

notes:
  Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.

  El JDK se descarga desde Adoptium (Eclipse Temurin) via su API oficial. La URL de cmdline-tools apunta a la version 11076708 (2024). Si Google actualiza la version, cambiar la URL con el nuevo numero de build.

  La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools con estructura `cmdline-tools/bin/...` pero sdkmanager espera estar en `cmdline-tools/latest/bin/sdkmanager` para que Android Studio y otras herramientas lo detecten correctamente.

  El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.

  Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.

code:
  #!/usr/bin/env bash
  # install_android_sdk — Descarga e instala Android SDK command-line tools y JDK 17
  # localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk).
  set -euo pipefail

  install_android_sdk() {
      local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
      local tmp_dir
      tmp_dir="$(mktemp -d)"

      # Limpia temporales al salir
      trap 'rm -rf "$tmp_dir"' EXIT

      # 1. Verifica si ya está instalado
      if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
          if JAVA_HOME="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1)" \
             "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" --version &>/dev/null; then
              echo "Android SDK ya instalado en: $sdk_dir"
              return 0
          fi
      fi

      mkdir -p "$sdk_dir"

      # 2. Descarga JDK 17 si no existe
      local jdk_dir
      jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"

      if [[ -z "$jdk_dir" ]]; then
          echo "Descargando JDK 17..."
          local jdk_tar="$tmp_dir/jdk17.tar.gz"
          local jdk_url="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"

          if ! curl -fL --progress-bar -o "$jdk_tar" "$jdk_url"; then
              echo "ERROR: fallo al descargar JDK 17 desde $jdk_url" >&2
              return 1
          fi

          mkdir -p "$sdk_dir/jdk-17"
          echo "Extrayendo JDK 17..."
          if ! tar -xzf "$jdk_tar" -C "$sdk_dir/jdk-17"; then
              echo "ERROR: fallo al extraer JDK 17" >&2
              return 1
          fi

          jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"
          if [[ -z "$jdk_dir" ]]; then
              echo "ERROR: no se encontro directorio jdk-17* tras la extraccion" >&2
              return 1
          fi

          if ! JAVA_HOME="$jdk_dir" "$jdk_dir/bin/java" -version &>/dev/null; then
              echo "ERROR: java -version fallo tras instalar JDK" >&2
              return 1
          fi
          echo "JDK 17 instalado: $jdk_dir"
      else
          echo "JDK 17 ya presente: $jdk_dir"
      fi

      export JAVA_HOME="$jdk_dir"

      # 3. Descarga Android cmdline-tools si no existen
      if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
          echo "Descargando Android cmdline-tools..."
          local tools_zip="$tmp_dir/cmdline-tools.zip"
          local tools_url="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"

          if ! curl -fL --progress-bar -o "$tools_zip" "$tools_url"; then
              echo "ERROR: fallo al descargar Android cmdline-tools desde $tools_url" >&2
              return 1
          fi

          local tools_tmp="$tmp_dir/cmdline-tools-extracted"
          mkdir -p "$tools_tmp"
          echo "Extrayendo cmdline-tools..."
          if ! unzip -q "$tools_zip" -d "$tools_tmp"; then
              echo "ERROR: fallo al extraer cmdline-tools" >&2
              return 1
          fi

          # La estructura del zip es cmdline-tools/bin/..., reorganizar a cmdline-tools/latest/
          mkdir -p "$sdk_dir/cmdline-tools"
          if [[ -d "$tools_tmp/cmdline-tools" ]]; then
              mv "$tools_tmp/cmdline-tools" "$sdk_dir/cmdline-tools/latest"
          else
              echo "ERROR: estructura inesperada en el zip de cmdline-tools" >&2
              return 1
          fi

          if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
              echo "ERROR: sdkmanager no encontrado tras extraer cmdline-tools" >&2
              return 1
          fi
          echo "cmdline-tools instalados"
      else
          echo "cmdline-tools ya presentes"
      fi

      local sdkmanager="$sdk_dir/cmdline-tools/latest/bin/sdkmanager"
      export ANDROID_HOME="$sdk_dir"
      export ANDROID_SDK_ROOT="$sdk_dir"
      export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"

      # 4. Acepta licencias e instala paquetes necesarios
      echo "Aceptando licencias de Android SDK..."
      if ! yes | "$sdkmanager" --licenses; then
          echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
          return 1
      fi

      echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
      if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
          echo "ERROR: fallo al instalar paquetes de Android SDK" >&2
          return 1
      fi

      # 5. Genera archivo de entorno
      local env_file="$sdk_dir/env.sh"
      cat > "$env_file" <<EOF
  export JAVA_HOME="$JAVA_HOME"
  export ANDROID_HOME="$sdk_dir"
  export ANDROID_SDK_ROOT="$sdk_dir"
  export PATH="\$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:\$PATH"
  EOF

      # 6. Resumen final
      echo ""
      echo "Android SDK instalado en: $sdk_dir"
      echo "JDK 17: $JAVA_HOME"
      echo "Para activar: source $sdk_dir/env.sh"
  }

  install_android_sdk


id: install_cpp_deps_bash_infra
name: install_cpp_deps
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_cpp_deps() -> void
tags: ["cpp","dependencies","setup","cmake","imgui"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_cpp_deps.sh
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
variant: []
content_hash: 012666be7f45769da894947f5e9ab7d3946f9377405d3e3204c4c12d8e1b11f1
params_schema: {"output":"Instala paquetes faltantes via apt o confirma que todo esta instalado"}
description:
  Verifica e instala las dependencias de sistema necesarias para compilar C++ con ImGui (cmake, g++, glfw, mesa)

documentation:
  # install_cpp_deps

  Verifica las dependencias necesarias para el build C++:

  - `cmake` — sistema de build

  - `g++` / `build-essential` — compilador

  - `libglfw3-dev` — windowing (GLFW)

  - `libgl1-mesa-dev` — OpenGL headers

  Tambien reporta si `mingw-w64` esta disponible para cross-compile a Windows.

  ```bash

  fn run install_cpp_deps

  ```

code:
  #!/usr/bin/env bash
  set -euo pipefail

  echo "[install_cpp_deps] Checking C++ build dependencies..."

  MISSING=()

  if ! command -v cmake &>/dev/null; then
      MISSING+=(cmake)
  else
      echo "  cmake: $(cmake --version | head -1)"
  fi

  if ! command -v g++ &>/dev/null; then
      MISSING+=(g++ build-essential)
  else
      echo "  g++: $(g++ --version | head -1)"
  fi

  if ! dpkg -s libglfw3-dev &>/dev/null 2>&1; then
      MISSING+=(libglfw3-dev)
  else
      echo "  libglfw3-dev: installed"
  fi

  if ! dpkg -s libgl1-mesa-dev &>/dev/null 2>&1; then
      MISSING+=(libgl1-mesa-dev)
  else
      echo "  libgl1-mesa-dev: installed"
  fi

  # Optional: mingw for cross-compile
  if command -v x86_64-w64-mingw32-g++ &>/dev/null; then
      echo "  mingw-w64: $(x86_64-w64-mingw32-g++ --version | head -1)"
  else
      echo "  mingw-w64: not installed (optional, for Windows cross-compile)"
  fi

  if [ ${#MISSING[@]} -eq 0 ]; then
      echo "[install_cpp_deps] All dependencies satisfied."
      exit 0
  fi

  echo ""
  echo "[install_cpp_deps] Missing packages: ${MISSING[*]}"
  echo "[install_cpp_deps] Installing..."
  sudo apt-get update -qq
  sudo apt-get install -y -qq "${MISSING[@]}"
  echo "[install_cpp_deps] Done."


id: install_mantine_bash_infra
name: install_mantine
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_mantine(project_dir: string) -> void
tags: ["mantine","frontend","install","react","ui","postcss"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_mantine.sh
created_at: 2026-04-05T23:51:12Z
updated_at: 2026-04-05T23:51:12Z
props: []
emits: []
variant: []
content_hash: ff562680c5e966c914ae32e8f04b4ee0d3107b2cb786eaadb9463600bb87841c
description:
  Instala Mantine UI con todas sus dependencias (@mantine/core, hooks, charts, notifications, form) y PostCSS en un proyecto frontend. Detecta package manager por lockfile. Genera postcss.config.cjs si no existe. Idempotente.

example:
  ```bash
  # Instalar Mantine en un proyecto con pnpm
  source install_mantine.sh
  install_mantine ./apps/rapid_dashboards/frontend

  # Uso directo
  bash install_mantine.sh ./frontend
  ```

notes:
  Detecta el package manager por lockfile: pnpm-lock.yaml → pnpm, yarn.lock → yarn, package-lock.json → npm. Instala las dependencias core de Mantine v7+ y el stack PostCSS necesario. Si postcss.config.cjs ya existe no lo sobreescribe.

code:
  # install_mantine
  # ---------------
  # Instala dependencias de Mantine UI en un proyecto frontend.
  # Detecta package manager por lockfile (pnpm > yarn > npm).
  # Genera postcss.config.cjs si no existe.
  # Idempotente: no reinstala si ya estan presentes.
  #
  # USO (sourced):
  #   source install_mantine.sh
  #   install_mantine /path/to/frontend
  #
  # USO (directo):
  #   bash install_mantine.sh /path/to/frontend

  install_mantine() {
      local project_dir="$1"

      if [ -z "$project_dir" ]; then
          echo "install_mantine: se requiere project_dir" >&2
          return 1
      fi

      if [ ! -f "$project_dir/package.json" ]; then
          echo "install_mantine: no existe package.json en $project_dir" >&2
          return 1
      fi

      # Detectar package manager
      local pm="npm"
      local add_cmd="install"
      local add_dev_flag="--save-dev"
      if [ -f "$project_dir/pnpm-lock.yaml" ] || [ -f "$project_dir/pnpm-workspace.yaml" ]; then
          pm="pnpm"
          add_cmd="add"
          add_dev_flag="-D"
      elif [ -f "$project_dir/yarn.lock" ]; then
          pm="yarn"
          add_cmd="add"
          add_dev_flag="--dev"
      elif [ -f "$project_dir/package-lock.json" ]; then
          pm="npm"
          add_cmd="install"
          add_dev_flag="--save-dev"
      fi

      echo "Detectado package manager: $pm"

      # Dependencias runtime
      local runtime_deps="@mantine/core @mantine/hooks @mantine/charts @mantine/notifications @mantine/form"
      echo "Instalando dependencias Mantine..."
      (cd "$project_dir" && $pm $add_cmd $runtime_deps 2>&1)

      if [ $? -ne 0 ]; then
          echo "install_mantine: fallo instalando dependencias runtime" >&2
          return 1
      fi

      # Dependencias PostCSS (dev)
      local dev_deps="postcss postcss-preset-mantine postcss-simple-vars"
      echo "Instalando dependencias PostCSS..."
      (cd "$project_dir" && $pm $add_cmd $add_dev_flag $dev_deps 2>&1)

      if [ $? -ne 0 ]; then
          echo "install_mantine: fallo instalando dependencias PostCSS" >&2
          return 1
      fi

      # Generar postcss.config.cjs si no existe
      if [ ! -f "$project_dir/postcss.config.cjs" ]; then
          echo "Generando postcss.config.cjs..."
          cat > "$project_dir/postcss.config.cjs" << 'POSTCSS'
  module.exports = {
    plugins: {
      'postcss-preset-mantine': {},
      'postcss-simple-vars': {
        variables: {
          'mantine-breakpoint-xs': '36em',
          'mantine-breakpoint-sm': '48em',
          'mantine-breakpoint-md': '62em',
          'mantine-breakpoint-lg': '75em',
          'mantine-breakpoint-xl': '88em',
        },
      },
    },
  };
  POSTCSS
          echo "postcss.config.cjs creado"
      else
          echo "postcss.config.cjs ya existe, no se sobreescribe"
      fi

      echo "Mantine instalado correctamente en $project_dir"
  }

  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_mantine "$@"
  fi

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto frontend con package.json"}],"output":"sin salida; muestra progreso de instalación"}


id: install_nbconvert_bash_infra
name: install_nbconvert
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_nbconvert(project_dir: string) -> void
tags: ["jupyter","nbconvert","pdf","export","playwright","python","uv"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source install_nbconvert.sh
  install_nbconvert /home/lucas/analysis/finanzas
  ```

tests: []
file_path: bash/functions/infra/install_nbconvert.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: be6ee004a968c0023937c202d2e84019de4b1d71368e03571d611deed2063d3b
description:
  Instala nbconvert y playwright con chromium en un proyecto uv existente. Idempotente: uv add no reinstala si los paquetes ya estan presentes.

notes:
  Requiere que el venv ya exista (usa `init_uv_venv` antes). La instalacion de chromium via `uv run playwright install chromium` puede tardar la primera vez. La salida de playwright se suprime si tiene exito — solo se muestra si hay un error.

code:
  # install_nbconvert
  # ------------------
  # Instala nbconvert y playwright con chromium en un proyecto uv existente.
  # Idempotente: uv add no reinstala si los paquetes ya estan presentes.
  #
  # USO (sourced):
  #   source install_nbconvert.sh
  #   install_nbconvert /path/to/project

  install_nbconvert() {
      local project_dir="$1"

      if [ -z "$project_dir" ]; then
          echo "install_nbconvert: se requiere project_dir" >&2
          return 1
      fi

      if [ ! -d "$project_dir/.venv" ]; then
          echo "install_nbconvert: no existe .venv en $project_dir — ejecuta init_uv_venv primero" >&2
          return 1
      fi

      # Instalar nbconvert y playwright via uv add
      (cd "$project_dir" && uv add nbconvert playwright 2>&1)

      # Instalar chromium — capturar output, solo mostrar si hay error
      local playwright_output
      if ! playwright_output=$(cd "$project_dir" && uv run playwright install chromium 2>&1); then
          echo "$playwright_output" >&2
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto con venv existente"}],"output":"sin salida"}


id: install_nordvpn_bash_infra
name: install_nordvpn
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_nordvpn() -> void
tags: ["vpn","nordvpn","install","infra","wsl2"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_nordvpn.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 57b7aebbc251da1f0d0c4680aef0f2734275a1932a0bfb85c596f41f481f047d
params_schema: {"output":"sin salida; muestra estado de instalación"}
description:
  Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2). Configura repositorio oficial, instala paquete y habilita servicio nordvpnd. Idempotente.

example:
  ```bash
  source install_nordvpn.sh
  install_nordvpn
  # nordvpn ya instalado: NordVPN Version 3.x.x
  # — o —
  # Instalando NordVPN CLI...
  # NordVPN instalado: NordVPN Version 3.x.x
  # NOTA: ejecuta 'nordvpn login' para autenticarte
  ```

notes:
  Usa el script de instalacion oficial de NordVPN. En WSL2 sin systemd, levanta nordvpnd manualmente. Agrega el usuario al grupo nordvpn para evitar sudo en comandos posteriores. Despues de instalar, se requiere `nordvpn login` para autenticarse.

code:
  # install_nordvpn
  # ---------------
  # Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2).
  # Configura el repositorio oficial, instala el paquete y habilita el servicio.
  # Si ya esta instalado, no hace nada.
  #
  # USO (sourced):
  #   source install_nordvpn.sh
  #   install_nordvpn

  install_nordvpn() {
      if command -v nordvpn &>/dev/null; then
          echo "nordvpn ya instalado: $(nordvpn version 2>/dev/null)"
          return 0
      fi

      echo "Instalando NordVPN CLI..."

      # Descargar e instalar via script oficial
      sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh) 2>&1

      if ! command -v nordvpn &>/dev/null; then
          echo "install_nordvpn: fallo la instalacion" >&2
          return 1
      fi

      # Agregar usuario al grupo nordvpn para evitar sudo
      sudo usermod -aG nordvpn "$USER" 2>/dev/null || true

      # Habilitar servicio (systemd o manual para WSL2)
      if command -v systemctl &>/dev/null && systemctl is-system-running &>/dev/null 2>&1; then
          sudo systemctl enable --now nordvpnd 2>/dev/null || true
      else
          # WSL2 sin systemd — levantar daemon manualmente
          sudo nordvpnd &>/dev/null &
          sleep 2
      fi

      echo "NordVPN instalado: $(nordvpn version 2>/dev/null)"
      echo "NOTA: ejecuta 'nordvpn login' para autenticarte"
  }


id: nordvpn_connect_bash_infra
name: nordvpn_connect
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_connect(country?: string, city?: string) -> json
tags: ["vpn","nordvpn","connect","infra","network"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/nordvpn_connect.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: ccdb98fc2ce2c69f41b1dcaaaac86d601b147ace796f93091c8bba9d47dd93e4
description:
  Conecta a NordVPN por pais, ciudad o servidor especifico. Sin argumentos conecta al mejor servidor disponible. Devuelve JSON con resultado.

example:
  ```bash
  source nordvpn_connect.sh

  nordvpn_connect
  # {"ok":true,"server":"us1234.nordvpn.com","country":"auto","city":"auto"}

  nordvpn_connect Spain
  # {"ok":true,"server":"es42.nordvpn.com","country":"Spain","city":"auto"}

  nordvpn_connect Spain Madrid
  # {"ok":true,"server":"es15.nordvpn.com","country":"Spain","city":"Madrid"}
  ```

notes:
  Requiere NordVPN CLI instalado y autenticado (`nordvpn login`). La salida JSON facilita composicion con otros scripts y pipelines. Si ya hay una conexion activa, NordVPN reconecta automaticamente al nuevo destino.

code:
  # nordvpn_connect
  # ---------------
  # Conecta a NordVPN. Acepta pais, ciudad o servidor especifico.
  # Sin argumentos conecta al mejor servidor disponible.
  # Imprime JSON con el resultado de la conexion.
  #
  # USO (sourced):
  #   source nordvpn_connect.sh
  #   nordvpn_connect                    # mejor servidor
  #   nordvpn_connect Spain              # por pais
  #   nordvpn_connect Spain Madrid       # por ciudad
  #   nordvpn_connect Spain '#42'        # servidor especifico

  nordvpn_connect() {
      local country="${1:-}"
      local city="${2:-}"

      if ! command -v nordvpn &>/dev/null; then
          echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
          return 1
      fi

      local args=()
      [ -n "$country" ] && args+=("$country")
      [ -n "$city" ] && args+=("$city")

      local output
      output=$(nordvpn connect "${args[@]}" 2>&1)
      local rc=$?

      if [ $rc -eq 0 ] && echo "$output" | grep -qi "connected"; then
          local server
          server=$(echo "$output" | grep -oP '(?<=to )\S+' | head -1)
          echo "{\"ok\":true,\"server\":\"${server}\",\"country\":\"${country:-auto}\",\"city\":\"${city:-auto}\"}"
      else
          echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"country","desc":"país de destino (opcional; default: auto)"},{"name":"city","desc":"ciudad de destino (opcional; default: auto)"}],"output":"JSON con ok, server, country, city"}


id: nordvpn_disconnect_bash_infra
name: nordvpn_disconnect
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_disconnect() -> json
tags: ["vpn","nordvpn","disconnect","infra","network"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source nordvpn_disconnect.sh

  nordvpn_disconnect
  # {"ok":true,"status":"disconnected"}
  ```

tests: []
file_path: bash/functions/infra/nordvpn_disconnect.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Idempotente: si no hay conexion activa, retorna ok sin error. Requiere NordVPN CLI instalado.
content_hash: ba29b2977ffedf5c85c1264d693348eb21aeefa38636ff85c815afb7e5acafc8
params_schema: {"output":"JSON con ok y status"}
description:
  Desconecta de NordVPN. Idempotente — si no hay conexion activa retorna ok. Devuelve JSON con resultado.

code:
  # nordvpn_disconnect
  # ------------------
  # Desconecta de NordVPN. Idempotente — si no hay conexion activa, retorna ok.
  # Imprime JSON con el resultado.
  #
  # USO (sourced):
  #   source nordvpn_disconnect.sh
  #   nordvpn_disconnect

  nordvpn_disconnect() {
      if ! command -v nordvpn &>/dev/null; then
          echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
          return 1
      fi

      local output
      output=$(nordvpn disconnect 2>&1)
      local rc=$?

      if [ $rc -eq 0 ] || echo "$output" | grep -qi "not connected\|disconnected"; then
          echo '{"ok":true,"status":"disconnected"}'
      else
          echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
          return 1
      fi
  }


id: nordvpn_get_ip_bash_infra
name: nordvpn_get_ip
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_get_ip() -> json
tags: ["vpn","nordvpn","ip","infra","network","verification"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/nordvpn_get_ip.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: f056919ea70f5d64d7dee9f505ba5657dd47102db33419cb12c8b1c8d9bec4ea
params_schema: {"output":"JSON con ok, ip, vpn_connected, vpn_server, source"}
description:
  Obtiene IP publica actual con fallback entre multiples servicios. Indica si la conexion VPN esta activa y el servidor usado.

example:
  ```bash
  source nordvpn_get_ip.sh

  # Con VPN activa:
  nordvpn_get_ip
  # {"ok":true,"ip":"185.x.x.x","vpn_connected":true,"vpn_server":"es42.nordvpn.com","source":"https://api.ipify.org"}

  # Sin VPN:
  nordvpn_get_ip
  # {"ok":true,"ip":"88.x.x.x","vpn_connected":false,"vpn_server":"","source":"https://api.ipify.org"}
  ```

notes:
  Usa ipify.org como servicio primario con fallback a ifconfig.me e icanhazip.com. Timeout de 5 segundos por servicio. Util para verificar que el tunel VPN esta activo antes de ejecutar operaciones sensibles a la IP.

code:
  # nordvpn_get_ip
  # --------------
  # Obtiene la IP publica actual para verificar que el tunel VPN funciona.
  # Usa multiples servicios como fallback.
  #
  # USO (sourced):
  #   source nordvpn_get_ip.sh
  #   nordvpn_get_ip

  nordvpn_get_ip() {
      local ip=""
      local source=""

      # Intentar multiples servicios
      for svc in "https://api.ipify.org" "https://ifconfig.me" "https://icanhazip.com"; do
          ip=$(curl -s --max-time 5 "$svc" 2>/dev/null)
          if echo "$ip" | grep -qP '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'; then
              source="$svc"
              break
          fi
          ip=""
      done

      if [ -z "$ip" ]; then
          echo '{"ok":false,"error":"no se pudo obtener IP publica"}' >&2
          return 1
      fi

      # Si nordvpn esta disponible, incluir info de conexion
      local connected="false"
      local vpn_server=""
      if command -v nordvpn &>/dev/null; then
          local status_output
          status_output=$(nordvpn status 2>/dev/null)
          if echo "$status_output" | grep -qi "connected"; then
              connected="true"
              vpn_server=$(echo "$status_output" | grep -iP "hostname|server" | head -1 | sed 's/.*: *//')
          fi
      fi

      echo "{\"ok\":true,\"ip\":\"$ip\",\"vpn_connected\":$connected,\"vpn_server\":\"$vpn_server\",\"source\":\"$source\"}"
  }


id: nordvpn_list_cities_bash_infra
name: nordvpn_list_cities
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_list_cities(country: string) -> json
description: Lista ciudades disponibles de un pais en NordVPN como array JSON ordenado.
tags: ["vpn","nordvpn","cities","infra","network"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/nordvpn_list_cities.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: ade8b580824fc744cac0e9636f1614e24f323d9d41390564f47e53a0538bbf9a
example:
  ```bash
  source nordvpn_list_cities.sh

  nordvpn_list_cities Spain
  # {"ok":true,"country":"Spain","count":2,"cities":["Barcelona","Madrid"]}

  nordvpn_list_cities "United_States"
  # {"ok":true,"country":"United_States","count":15,"cities":["Atlanta","Buffalo",...]}
  ```

notes:
  El nombre de pais debe coincidir con lo que devuelve `nordvpn countries`. Usa underscores para paises compuestos (ej: United_States). Las ciudades se devuelven con espacios.

code:
  # nordvpn_list_cities
  # -------------------
  # Lista las ciudades disponibles de un pais en NordVPN como array JSON.
  #
  # USO (sourced):
  #   source nordvpn_list_cities.sh
  #   nordvpn_list_cities Spain

  nordvpn_list_cities() {
      local country="${1:?nordvpn_list_cities: se requiere pais como argumento}"

      if ! command -v nordvpn &>/dev/null; then
          echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
          return 1
      fi

      local output
      output=$(nordvpn cities "$country" 2>&1)
      local rc=$?

      if [ $rc -ne 0 ]; then
          echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
          return 1
      fi

      echo "$output" | python3 -c '
  import sys, json, re

  country = "'"$country"'"
  text = sys.stdin.read()
  text = re.sub(r"\x1b\[[0-9;]*m", "", text)
  text = re.sub(r"[\t\r]", " ", text)
  cities = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"]
  cities = [c for c in cities if len(c) > 1]
  cities.sort()
  print(json.dumps({"ok": True, "country": country, "count": len(cities), "cities": cities}))
  '
  }

params_schema:
  {"params":[{"name":"country","desc":"nombre del país en NordVPN (ej: Spain, United_States)"}],"output":"JSON con ok, country, count, cities (array de strings)"}


id: nordvpn_list_countries_bash_infra
name: nordvpn_list_countries
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_list_countries() -> json
description: Lista paises disponibles en NordVPN como array JSON ordenado alfabeticamente.
tags: ["vpn","nordvpn","countries","infra","network"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/nordvpn_list_countries.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b8e9dae18e9fc5505238584cdeb997cdebe47eb15e130254bc667c1f035525df
params_schema: {"output":"JSON con ok, count, countries (array de strings ordenado alfabéticamente)"}
example:
  ```bash
  source nordvpn_list_countries.sh

  nordvpn_list_countries
  # {"ok":true,"count":60,"countries":["Albania","Argentina","Australia",...,"United States","Vietnam"]}
  ```

notes:
  Parsea la salida de `nordvpn countries` eliminando codigos ANSI y normalizando separadores. Los nombres de paises se devuelven con espacios en vez de underscores.

code:
  # nordvpn_list_countries
  # ----------------------
  # Lista los paises disponibles en NordVPN como array JSON.
  #
  # USO (sourced):
  #   source nordvpn_list_countries.sh
  #   nordvpn_list_countries

  nordvpn_list_countries() {
      if ! command -v nordvpn &>/dev/null; then
          echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
          return 1
      fi

      local output
      output=$(nordvpn countries 2>&1)
      local rc=$?

      if [ $rc -ne 0 ]; then
          echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
          return 1
      fi

      echo "$output" | python3 -c '
  import sys, json, re

  text = sys.stdin.read()
  text = re.sub(r"\x1b\[[0-9;]*m", "", text)
  text = re.sub(r"[\t\r]", " ", text)
  # Split by comma, whitespace, or newline and clean
  countries = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"]
  countries = [c for c in countries if len(c) > 1]
  countries.sort()
  print(json.dumps({"ok": True, "count": len(countries), "countries": countries}))
  '
  }


id: nordvpn_set_protocol_bash_infra
name: nordvpn_set_protocol
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_set_protocol(protocol: string) -> json
tags: ["vpn","nordvpn","protocol","nordlynx","wireguard","openvpn","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/nordvpn_set_protocol.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 41343a8927e771c3b6b46241a2b813a900b05f20c821373de8faa403ad5019f0
description:
  Cambia el protocolo de NordVPN entre NordLynx (WireGuard) y OpenVPN. NordLynx recomendado por velocidad.

example:
  ```bash
  source nordvpn_set_protocol.sh

  nordvpn_set_protocol NordLynx
  # {"ok":true,"protocol":"NordLynx"}

  nordvpn_set_protocol OpenVPN
  # {"ok":true,"protocol":"OpenVPN"}
  ```

notes:
  NordLynx es WireGuard wrapeado por NordVPN — mas rapido y moderno. OpenVPN es mas compatible con redes restrictivas. El cambio de protocolo requiere reconectar si hay una conexion activa.

code:
  # nordvpn_set_protocol
  # --------------------
  # Cambia el protocolo de NordVPN (NordLynx o OpenVPN).
  # NordLynx = WireGuard (recomendado por velocidad).
  #
  # USO (sourced):
  #   source nordvpn_set_protocol.sh
  #   nordvpn_set_protocol NordLynx
  #   nordvpn_set_protocol OpenVPN

  nordvpn_set_protocol() {
      local protocol="${1:?nordvpn_set_protocol: se requiere protocolo (NordLynx|OpenVPN)}"

      if ! command -v nordvpn &>/dev/null; then
          echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
          return 1
      fi

      case "$protocol" in
          NordLynx|nordlynx|NORDLYNX) protocol="NordLynx" ;;
          OpenVPN|openvpn|OPENVPN)     protocol="OpenVPN" ;;
          *)
              echo "{\"ok\":false,\"error\":\"protocolo invalido: $protocol (usar NordLynx o OpenVPN)\"}"
              return 1
              ;;
      esac

      local output
      output=$(nordvpn set protocol "$protocol" 2>&1)
      local rc=$?

      if [ $rc -eq 0 ] || echo "$output" | grep -qi "already set\|successfully"; then
          echo "{\"ok\":true,\"protocol\":\"$protocol\"}"
      else
          echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"protocol","desc":"protocolo a usar: NordLynx (WireGuard) u OpenVPN"}],"output":"JSON con ok y protocol confirmado"}


id: nordvpn_status_bash_infra
name: nordvpn_status
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_status() -> json
tags: ["vpn","nordvpn","status","infra","network"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/nordvpn_status.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 75dd5ab1ac03ac7fed4abb7dfa5edb55b723e2b702306ba4c178023b3041eb5d
params_schema: {"output":"JSON con estado de VPN: ok, connected, status, hostname, ip, country, city, etc"}
description:
  Obtiene estado actual de NordVPN como JSON estructurado. Incluye servidor, IP, pais, protocolo y estado de conexion.

example:
  ```bash
  source nordvpn_status.sh

  nordvpn_status
  # {"ok":true,"connected":true,"status":"Connected","hostname":"es42.nordvpn.com","ip":"185.x.x.x","country":"Spain","city":"Madrid","current_technology":"NordLynx","current_protocol":"nordlynx","transfer":"1.2 MiB received, 500 KiB sent","uptime":"5 minutes 32 seconds"}

  # Desconectado:
  # {"ok":true,"connected":false,"status":"Disconnected"}
  ```

notes:
  Parsea la salida clave-valor de `nordvpn status` eliminando codigos ANSI. Los campos disponibles dependen del estado de conexion — cuando esta desconectado solo devuelve status y connected.

code:
  # nordvpn_status
  # --------------
  # Obtiene el estado actual de NordVPN como JSON estructurado.
  # Parsea la salida clave-valor de `nordvpn status` a campos JSON.
  #
  # USO (sourced):
  #   source nordvpn_status.sh
  #   nordvpn_status

  nordvpn_status() {
      if ! command -v nordvpn &>/dev/null; then
          echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
          return 1
      fi

      local output
      output=$(nordvpn status 2>&1)
      local rc=$?

      if [ $rc -ne 0 ]; then
          echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
          return 1
      fi

      # Parsear output clave: valor a JSON con python3
      echo "$output" | python3 -c '
  import sys, json, re

  lines = sys.stdin.read().strip().split("\n")
  data = {"ok": True}
  for line in lines:
      line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip()
      line = line.lstrip("- ")
      if ":" in line:
          key, _, val = line.partition(":")
          key = key.strip().lower().replace(" ", "_")
          val = val.strip()
          if key == "status":
              data["connected"] = val.lower() == "connected"
          data[key] = val
  print(json.dumps(data))
  '
  }


id: notebook_to_pdf_bash_infra
name: notebook_to_pdf
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: notebook_to_pdf(project_dir: string, [pattern: string], [output_dir: string]) -> string
tags: ["jupyter","notebook","pdf","export","nbconvert","playwright"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/notebook_to_pdf.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 25a8d0d0e83b68ff24096b322ad4d3f3d39ac3a5ace80116deae60bb1e9f4965
description:
  Convierte notebooks Jupyter a PDF usando nbconvert webpdf con chromium. Lista los PDFs generados al finalizar.

example:
  ```bash
  source notebook_to_pdf.sh

  # Con defaults (notebooks/*.ipynb -> notebooks/pdf/)
  notebook_to_pdf /home/lucas/analysis/finanzas

  # Con pattern y output_dir custom
  notebook_to_pdf /home/lucas/analysis/finanzas "notebooks/01_*.ipynb" "exports/pdf/"
  ```

notes:
  Requiere nbconvert y playwright con chromium instalados (usa `install_nbconvert` antes). Usa el venv del proyecto directamente (`.venv/bin/jupyter`). El output_dir es relativo a project_dir. Imprime los PDFs generados con sus rutas al finalizar. Falla si no se genera ningun PDF.

code:
  # notebook_to_pdf
  # ----------------
  # Convierte notebooks Jupyter a PDF usando nbconvert webpdf.
  # Requiere nbconvert y playwright con chromium instalados.
  #
  # USO (sourced):
  #   source notebook_to_pdf.sh
  #   notebook_to_pdf /path/to/project
  #   notebook_to_pdf /path/to/project "notebooks/*.ipynb" "notebooks/pdf/"

  notebook_to_pdf() {
      local project_dir="$1"
      local pattern="${2:-notebooks/*.ipynb}"
      local output_dir="${3:-notebooks/pdf/}"

      if [ -z "$project_dir" ]; then
          echo "notebook_to_pdf: se requiere project_dir" >&2
          return 1
      fi

      if [ ! -d "$project_dir/.venv" ]; then
          echo "notebook_to_pdf: no existe .venv en $project_dir" >&2
          return 1
      fi

      # Crear directorio de salida si no existe
      mkdir -p "$project_dir/$output_dir"

      # Convertir notebooks a PDF con nbconvert webpdf
      # nbconvert puede retornar exit != 0 por warnings de validacion JSON
      # que no impiden la generacion del PDF, asi que ignoramos el exit code
      # y verificamos que los PDFs se hayan generado
      local nbconvert_output
      nbconvert_output=$(cd "$project_dir" && \
          .venv/bin/jupyter nbconvert \
              --to webpdf \
              --allow-chromium-download \
              --output-dir="$output_dir" \
              $pattern 2>&1) || true

      echo "$nbconvert_output"

      # Listar PDFs generados
      echo ""
      echo "PDFs generados en ${project_dir}/${output_dir}:"
      local pdf_count=0
      while IFS= read -r -d '' pdf; do
          echo "  $pdf"
          pdf_count=$((pdf_count + 1))
      done < <(find "$project_dir/$output_dir" -name "*.pdf" -print0 2>/dev/null)

      if [ "$pdf_count" -eq 0 ]; then
          echo "  (ninguno encontrado — nbconvert pudo haber fallado)" >&2
          echo "$nbconvert_output" >&2
          return 1
      fi

      echo "  Total: $pdf_count PDFs"
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio raíz del proyecto con venv y notebooks"},{"name":"pattern","desc":"glob de notebooks a convertir (default: notebooks/*.ipynb)"},{"name":"output_dir","desc":"directorio destino para PDFs relativo a project_dir (default: notebooks/pdf/)"}],"output":"lista de PDFs generados con sus rutas"}


id: pass_delete_bash_infra
name: pass_delete
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: pass_delete(entry: string) -> void
description: Elimina un secreto del password store (pass).
tags: ["pass","secret","credential","delete"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source pass_delete.sh
  pass_delete agentes/viejo-token
  ```

tests: ["elimina entrada de test","falla con entrada inexistente"]
test_file_path: bash/functions/infra/pass_test.sh
file_path: bash/functions/infra/pass_delete.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Usa `pass rm -f` para eliminar sin prompt de confirmacion.
content_hash: a8befe189a97a3c3f9c5b6284412886f11a77ec52acdaf51c52a181b72dd02c9
code:
  # pass_delete
  # -----------
  # Elimina un secreto del password store.
  # Sale con exit code 1 si la entrada no existe o pass falla.
  #
  # USO (sourced):
  #   source pass_delete.sh
  #   pass_delete agentes/viejo-token

  pass_delete() {
      local entry="$1"

      if [ -z "$entry" ]; then
          echo "pass_delete: se requiere nombre de entrada" >&2
          return 1
      fi

      if ! pass rm -f "$entry" >/dev/null 2>&1; then
          echo "pass_delete: fallo al eliminar '$entry'" >&2
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"entry","desc":"ruta de entrada en el password store (ej: agentes/token)"}],"output":"sin salida"}


id: pass_generate_bash_infra
name: pass_generate
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: pass_generate(entry: string, [length: int]) -> string
description: Genera un password aleatorio, lo almacena en el password store e imprime el valor generado.
tags: ["pass","secret","credential","generate","random"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: ["genera password de longitud especifica","default 24 chars"]
test_file_path: bash/functions/infra/pass_test.sh
file_path: bash/functions/infra/pass_generate.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Usa `pass generate -f -n` (force overwrite, no symbols). Default 24 caracteres alfanumericos.
content_hash: 0b51c948b911254b1cf275df02eed3c1e1a5fb990a7accc8e312e592484c352f
example:
  ```bash
  source pass_generate.sh
  new_pass=$(pass_generate agentes/nuevo-servicio 32)
  echo "password generado: $new_pass"
  ```

code:
  # pass_generate
  # -------------
  # Genera un password aleatorio y lo almacena en el password store.
  # Imprime el password generado a stdout.
  # Sale con exit code 1 si pass falla.
  #
  # USO (sourced):
  #   source pass_generate.sh
  #   pass_generate agentes/nuevo-token 32
  #   pass_generate agentes/api-key         # default 24 chars

  pass_generate() {
      local entry="$1"
      local length="${2:-24}"

      if [ -z "$entry" ]; then
          echo "pass_generate: se requiere nombre de entrada" >&2
          return 1
      fi

      local output
      output=$(pass generate -f -n "$entry" "$length" 2>&1)
      if [ $? -ne 0 ]; then
          echo "pass_generate: fallo al generar '$entry': $output" >&2
          return 1
      fi

      # pass generate imprime ANSI escape codes + header + password
      # Extraer ultima linea y limpiar escape codes
      echo "$output" | tail -1 | sed 's/\x1b\[[0-9;]*m//g'
  }

params_schema:
  {"params":[{"name":"entry","desc":"ruta de entrada en el password store"},{"name":"length","desc":"longitud del password (default: 24 caracteres)"}],"output":"password generado en texto plano"}


id: pass_get_bash_infra
name: pass_get
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: pass_get(entry: string) -> string
description: Lee un secreto del password store (pass) y lo imprime a stdout.
tags: ["pass","secret","credential","get"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: ["lee entrada existente","falla con entrada inexistente"]
test_file_path: bash/functions/infra/pass_test.sh
file_path: bash/functions/infra/pass_get.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 7845238d9c7859d3768a00d30f5664eefbbbcd27d8c25954d19f3f419a0d0dd3
example:
  ```bash
  source pass_get.sh
  token=$(pass_get agentes/dataforge-token)
  export GITEA_TOKEN="$token"
  ```

notes:
  Usa `pass show` internamente. Requiere GPG key desbloqueada. No imprime newline final (usa printf %s).

code:
  # pass_get
  # --------
  # Lee un secreto del password store y lo imprime a stdout.
  # Sale con exit code 1 si la entrada no existe o pass falla.
  #
  # USO (sourced):
  #   source pass_get.sh
  #   token=$(pass_get agentes/dataforge-token)

  pass_get() {
      local entry="$1"

      if [ -z "$entry" ]; then
          echo "pass_get: se requiere nombre de entrada" >&2
          return 1
      fi

      local value
      value=$(pass show "$entry" 2>/dev/null)
      if [ $? -ne 0 ]; then
          echo "pass_get: no se pudo leer '$entry'" >&2
          return 1
      fi

      printf '%s' "$value"
  }

params_schema:
  {"params":[{"name":"entry","desc":"ruta de entrada en el password store"}],"output":"valor del secreto en texto plano"}


id: pass_list_bash_infra
name: pass_list
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: pass_list([prefix: string]) -> json
description: Lista entradas del password store como JSON array. Filtra opcionalmente por prefijo.
tags: ["pass","secret","credential","list"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: ["lista todas las entradas","filtra por prefijo"]
test_file_path: bash/functions/infra/pass_test.sh
file_path: bash/functions/infra/pass_list.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 003a2c5146c97ca46821d37d0ec40037e042c531b420ba0956dbbc3ef2b51572
example:
  ```bash
  source pass_list.sh
  entries=$(pass_list agentes)
  # ["dataforge-token","egutierrez-token","gitea-url"]
  ```

notes:
  Parsea el output tree de `pass ls` y lo convierte a JSON array. Cada entrada es un string con el nombre relativo al prefijo.

code:
  # pass_list
  # ---------
  # Lista entradas del password store como JSON array.
  # Opcionalmente filtra por prefijo.
  # Sale con exit code 1 si pass falla.
  #
  # USO (sourced):
  #   source pass_list.sh
  #   pass_list                  # todas las entradas
  #   pass_list agentes          # solo bajo agentes/

  pass_list() {
      local prefix="${1:-}"

      local raw
      raw=$(pass ls "$prefix" 2>/dev/null)
      if [ $? -ne 0 ]; then
          echo "pass_list: fallo al listar entradas" >&2
          return 1
      fi

      # Parsear output de pass: extraer nombres limpios (sin tree chars)
      local entries
      entries=$(echo "$raw" | sed 's/[│├└──── ]//g' | sed '/^$/d' | grep -v '^Password' | grep -v '^[[:space:]]*$')

      # Construir JSON array
      printf '['
      local first=true
      while IFS= read -r line; do
          line=$(echo "$line" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
          [ -z "$line" ] && continue
          if [ "$first" = true ]; then
              first=false
          else
              printf ','
          fi
          printf '"%s"' "$line"
      done <<< "$entries"
      printf ']'
  }

params_schema:
  {"params":[{"name":"prefix","desc":"prefijo para filtrar entradas (opcional; ej: agentes)"}],"output":"JSON array de nombres de entradas"}


id: pass_set_bash_infra
name: pass_set
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: pass_set(entry: string, [value: string]) -> void
description: Inserta o sobreescribe un secreto en el password store (pass).
tags: ["pass","secret","credential","set","insert"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source pass_set.sh
  pass_set agentes/nuevo-servicio "token-abc123"
  ```

tests: ["inserta valor y lo lee de vuelta","sobreescribe valor existente"]
test_file_path: bash/functions/infra/pass_test.sh
file_path: bash/functions/infra/pass_set.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 8b8b3f80d17a9f3f4ac33e2b1080c1214e6b719aafe6cda8dba1a6536fbbcecc
notes:
  Usa `pass insert -m -f` para forzar sobreescritura sin prompt interactivo. Si no se pasa valor como argumento, lee de stdin.

code:
  # pass_set
  # --------
  # Inserta o sobreescribe un secreto en el password store.
  # Lee el valor de stdin si no se pasa como segundo argumento.
  # Sale con exit code 1 si pass falla.
  #
  # USO (sourced):
  #   source pass_set.sh
  #   pass_set agentes/nuevo-token "mi-token-secreto"
  #   echo "mi-token" | pass_set agentes/nuevo-token

  pass_set() {
      local entry="$1"
      local value="$2"

      if [ -z "$entry" ]; then
          echo "pass_set: se requiere nombre de entrada" >&2
          return 1
      fi

      if [ -n "$value" ]; then
          printf '%s' "$value" | pass insert -m -f "$entry" >/dev/null 2>&1
      else
          pass insert -m -f "$entry" >/dev/null 2>&1
      fi

      if [ $? -ne 0 ]; then
          echo "pass_set: fallo al escribir '$entry'" >&2
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"entry","desc":"ruta de entrada en el password store"},{"name":"value","desc":"valor del secreto (opcional; se lee de stdin si no se proporciona)"}],"output":"sin salida"}


id: pass_sync_bash_infra
name: pass_sync
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: pass_sync() -> json
description: Sincroniza el password store con el repositorio git remoto (pull + push).
tags: ["pass","secret","sync","git"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: ["sincroniza con remoto"]
test_file_path: bash/functions/infra/pass_test.sh
file_path: bash/functions/infra/pass_sync.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: c1a5fdca7d75b68514f4460c1a2eb61137465659facb4db1dcd14969e94bf793
params_schema: {"output":"JSON con resultados de pull y push"}
example:
  ```bash
  source pass_sync.sh
  result=$(pass_sync)
  # {"pull":"Already up to date.","push":"Everything up-to-date"}
  ```

notes:
  Ejecuta `pass git pull` seguido de `pass git push`. Requiere que el password store tenga un remote git configurado. Retorna JSON con la ultima linea de cada operacion.

code:
  # pass_sync
  # ---------
  # Sincroniza el password store con el repositorio git remoto (pull + push).
  # Sale con exit code 1 si la sincronizacion falla.
  #
  # USO (sourced):
  #   source pass_sync.sh
  #   pass_sync

  pass_sync() {
      local pull_out
      pull_out=$(pass git pull 2>&1)
      if [ $? -ne 0 ]; then
          echo "pass_sync: fallo en git pull: $pull_out" >&2
          return 1
      fi

      local push_out
      push_out=$(pass git push 2>&1)
      if [ $? -ne 0 ]; then
          echo "pass_sync: fallo en git push: $push_out" >&2
          return 1
      fi

      printf '{"pull":"%s","push":"%s"}' \
          "$(echo "$pull_out" | tail -1 | sed 's/"/\\"/g')" \
          "$(echo "$push_out" | tail -1 | sed 's/"/\\"/g')"
  }


id: uv_add_packages_bash_infra
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
tags: ["python","uv","pip","packages","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/uv_add_packages.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: f807adf1f48d52b92091c7c273f766d781e94ee95bb84908634f583aa688547c
description:
  Instala paquetes Python en un proyecto usando uv add con fallback a pip. Inicializa pyproject.toml si no existe.

example:
  ```bash
  source uv_add_packages.sh
  uv_add_packages /home/lucas/analysis/finanzas jupyter jupyterlab pandas numpy

  # Solo un paquete
  uv_add_packages . polars
  ```

notes:
  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.

code:
  # 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
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto con venv existente"},{"name":"packages","desc":"nombres de paquetes Python a instalar (variadic)"}],"output":"sin salida"}


id: write_claude_jupyter_rules_bash_infra
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
tags: ["claude","jupyter","rules","setup","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/write_claude_jupyter_rules.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 66e06d51635c07b708c2b274aeff6961ce58197db2ac3c66fa3816a0bbdb1030
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.

example:
  ```bash
  source write_claude_jupyter_rules.sh
  path=$(write_claude_jupyter_rules /home/lucas/analysis/finanzas)
  echo "Reglas escritas en: $path"
  ```

notes:
  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`.

code:
  # 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"
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto Jupyter (default: directorio actual)"}],"output":"ruta del archivo .claude/CLAUDE.md generado o actualizado"}


id: write_jupyter_launcher_bash_infra
name: write_jupyter_launcher
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: write_jupyter_launcher([project_dir: string]) -> string
tags: ["jupyter","launcher","setup","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/write_jupyter_launcher.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: c59337b64b64deec591f79895e9f25008bf166a7bd38613bf7671f37999eff01
description:
  Genera un script run-jupyter-lab.sh que lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto y token deshabilitado.

example:
  ```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]
  ```

notes:
  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

code:
  # 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)" \
      --collaborative
  LAUNCHER

      chmod +x "$launcher"
      echo "$launcher"
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto Jupyter (default: directorio actual)"}],"output":"ruta del script run-jupyter-lab.sh generado"}


id: write_jupyter_registry_kernel_bash_infra
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
tags: ["jupyter","ipython","kernel","registry","setup","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/write_jupyter_registry_kernel.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 0ee25c11974d164c5ceb94314112029d7636ab8210b9cfa735dd1688caa5da38
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.

example:
  ```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
  ```

notes:
  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`.

code:
  # 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"
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto Jupyter (default: directorio actual)"}],"output":"ruta del archivo startup generado"}


id: write_mcp_jupyter_config_bash_infra
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
tags: ["mcp","jupyter","config","setup","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/write_mcp_jupyter_config.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: ec2747315cff0aa1f460e7b84e6c6a0d48c67005534f729586341256be0ab91e
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.

example:
  ```bash
  source write_mcp_jupyter_config.sh
  path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
  echo "Config MCP en: $path"
  ```

notes:
  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.

code:
  # 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.server.
  # Configura via env vars (SERVER_URL, TOKEN) — no CLI args.
  # 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.server"],
        "env": {
          "SERVER_URL": "http://localhost:${port}",
          "TOKEN": ""
        }
      }
    }
  }
  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"
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto Jupyter (default: directorio actual)"},{"name":"port","desc":"puerto Jupyter (default: detectado automáticamente)"}],"output":"ruta del archivo .mcp.json generado o actualizado"}


id: audit_registry_paths_bash_pipelines
name: audit_registry_paths
kind: pipeline
lang: bash
domain: pipelines
version: 1.0.0
purity: impure
signature: audit_registry_paths([output_file: string]) -> void
tags: ["registry","audit","validation","paths","launcher","pipeline","bash"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/pipelines/audit_registry_paths.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 6b6fc9847fe3e4a13cb613cb0e725abce527c710b7c2c4e444e43a70c3aee39c
description:
  Audita file_path de todas las functions y types en registry.db, verifica que cada ruta apunte a un archivo existente en disco, y genera un txt con las rutas rotas para que agentes puedan corregirlas.

uses_functions:
  ["assert_command_exists_bash_shell","assert_file_exists_bash_shell","validate_registry_paths_bash_shell","report_execution_json_bash_shell","exit_with_status_bash_shell"]

example:
  ```bash
  # Con default (genera broken_paths.txt en la raiz)
  ./bash/functions/pipelines/audit_registry_paths.sh

  # Con ruta personalizada
  ./bash/functions/pipelines/audit_registry_paths.sh /tmp/broken.txt

  # Desde fn run (pipeline launcher)
  fn run audit_registry_paths
  ```

notes:
  El archivo de salida es consumible por agentes: cada linea tiene el ID de la funcion/tipo y el file_path que necesita correccion. El agente puede leer el .md correspondiente, encontrar el archivo real en disco, y actualizar el campo `file_path`.

documentation:
  ## Flujo

  1. `assert_command_exists` — verifica que `sqlite3` esta disponible
  2. `assert_file_exists` — verifica que `registry.db` existe y reporta su tamano
  3. `validate_registry_paths` (functions) — itera todas las functions, verifica cada file_path
  4. `validate_registry_paths` (types) — itera todos los types, verifica cada file_path
  5. Genera `broken_paths.txt` con formato legible para agentes
  6. `report_execution_json` — imprime JSON de ejecucion a stdout
  7. `exit_with_status` — exit code segun resultado

  ## Formato de salida

  ```
  # Broken file_path entries in registry.db
  # Generated: 2026-04-03T10:00:00Z
  # Total: 11 broken paths
  #
  # Format: id | file_path (in .md) | domain | table
  # ---

  ## Functions (11)

  cdp_click_go_browser | functions/infra/cdp_click.go | browser | functions
  ```

code:
  #!/usr/bin/env bash
  # audit_registry_paths
  # --------------------
  # Audita file_path de functions y types en registry.db.
  # Genera un txt con las rutas rotas para que agentes puedan arreglarlas.
  #
  # Compone: assert_command_exists + assert_file_exists +
  #          validate_registry_paths + report_execution_json + exit_with_status
  #
  # USO:
  #   ./audit_registry_paths.sh [OUTPUT_FILE]
  #
  # ARGUMENTOS (opcionales):
  #   OUTPUT_FILE   Ruta del archivo de salida
  #                 Default: $REGISTRY_ROOT/broken_paths.txt

  set -euo pipefail

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

  source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
  source "$REGISTRY_ROOT/bash/functions/shell/assert_file_exists.sh"
  source "$REGISTRY_ROOT/bash/functions/shell/validate_registry_paths.sh"
  source "$REGISTRY_ROOT/bash/functions/shell/report_execution_json.sh"
  source "$REGISTRY_ROOT/bash/functions/shell/exit_with_status.sh"

  OUTPUT_FILE="${1:-$REGISTRY_ROOT/broken_paths.txt}"
  DB_PATH="$REGISTRY_ROOT/registry.db"

  STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  START_MS=$(date +%s%3N)
  STEPS_FILE=$(mktemp)
  trap 'rm -f "$STEPS_FILE"' EXIT

  ok_steps=0
  failed_steps=0

  log_step() {
      local name="$1" action="$2" status="$3" elapsed="$4" output="${5:-}" error="${6:-}"
      printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$name" "$action" "$status" "$elapsed" "$output" "$error" >> "$STEPS_FILE"
      if [[ "$status" == "ok" ]]; then ok_steps=$((ok_steps + 1)); else failed_steps=$((failed_steps + 1)); fi
  }

  # Paso 1: verificar sqlite3
  step_start=$(date +%s%3N)
  if assert_command_exists sqlite3; then
      log_step "assert_command_exists" "check sqlite3" "ok" $(( $(date +%s%3N) - step_start ))
  else
      log_step "assert_command_exists" "check sqlite3" "error" $(( $(date +%s%3N) - step_start )) "" "sqlite3 not found"
      ENDED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
      DURATION=$(( $(date +%s%3N) - START_MS ))
      set +e; report_execution_json "audit_registry_paths" "failure" 1 "$STARTED_AT" "$ENDED_AT" "$DURATION" "$STEPS_FILE"; set -e
      exit 1
  fi

  # Paso 2: verificar registry.db
  step_start=$(date +%s%3N)
  if db_size=$(assert_file_exists "$DB_PATH"); then
      log_step "assert_file_exists" "check registry.db" "ok" $(( $(date +%s%3N) - step_start )) "${db_size} bytes"
  else
      log_step "assert_file_exists" "check registry.db" "error" $(( $(date +%s%3N) - step_start )) "" "registry.db not found"
      ENDED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
      DURATION=$(( $(date +%s%3N) - START_MS ))
      set +e; report_execution_json "audit_registry_paths" "failure" 1 "$STARTED_AT" "$ENDED_AT" "$DURATION" "$STEPS_FILE"; set -e
      exit 1
  fi

  # Paso 3: validar functions
  step_start=$(date +%s%3N)
  fn_broken=$(validate_registry_paths "$DB_PATH" functions "$REGISTRY_ROOT")
  fn_count=$(printf '%s' "$fn_broken" | grep -c . || true)
  log_step "validate_registry_paths" "check functions" "ok" $(( $(date +%s%3N) - step_start )) "$fn_count broken"

  # Paso 4: validar types
  step_start=$(date +%s%3N)
  ty_broken=$(validate_registry_paths "$DB_PATH" types "$REGISTRY_ROOT")
  ty_count=$(printf '%s' "$ty_broken" | grep -c . || true)
  log_step "validate_registry_paths" "check types" "ok" $(( $(date +%s%3N) - step_start )) "$ty_count broken"

  # Paso 5: generar archivo de salida
  step_start=$(date +%s%3N)
  total_broken=$(( fn_count + ty_count ))

  {
      echo "# Broken file_path entries in registry.db"
      echo "# Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
      echo "# Total: $total_broken broken paths"
      echo "#"
      echo "# Format: id | file_path (in .md) | domain | table"
      echo "# ---"
      if [[ $total_broken -eq 0 ]]; then
          echo "# All paths are valid."
      else
          if [[ -n "$fn_broken" ]]; then
              echo ""
              echo "## Functions ($fn_count)"
              printf '%s\n' "$fn_broken" | while IFS=$'\t' read -r id fp domain table; do
                  echo "$id | $fp | $domain | $table"
              done
          fi
          if [[ -n "$ty_broken" ]]; then
              echo ""
              echo "## Types ($ty_count)"
              printf '%s\n' "$ty_broken" | while IFS=$'\t' read -r id fp domain table; do
                  echo "$id | $fp | $domain | $table"
              done
          fi
      fi
  } > "$OUTPUT_FILE"

  log_step "write_output" "generate $OUTPUT_FILE" "ok" $(( $(date +%s%3N) - step_start )) "$total_broken entries"

  # Reporte final
  ENDED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  DURATION=$(( $(date +%s%3N) - START_MS ))
  total_steps=$(( ok_steps + failed_steps ))

  if [[ $total_broken -eq 0 ]]; then
      status="success"
  else
      status="partial"
  fi

  set +e
  report_execution_json "audit_registry_paths" "$status" 0 "$STARTED_AT" "$ENDED_AT" "$DURATION" "$STEPS_FILE"
  set -e

  echo ""
  echo "--- Results ---"
  echo "Broken paths: $total_broken"
  echo "Output: $OUTPUT_FILE"

  exit_with_status "$total_steps" "$ok_steps" "$failed_steps" > /dev/null

params_schema:
  {"params":[{"name":"output_file","desc":"archivo de salida con rutas rotas (default: broken_paths.txt en raíz)"}],"output":"sin salida directa; genera archivo con rutas inválidas"}


id: capacitor_build_apk_bash_pipelines
name: capacitor_build_apk
kind: pipeline
lang: bash
domain: pipelines
version: 1.0.0
purity: impure
signature: capacitor_build_apk(web_app_dir: string, [app_id: string], [app_name: string]) -> void
tags: ["android","apk","capacitor","mobile","build","pipeline","bash"]
uses_functions: ["install_android_sdk_bash_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/pipelines/capacitor_build_apk.sh
created_at: 2026-04-06T16:40:45Z
updated_at: 2026-04-06T16:40:45Z
props: []
emits: []
variant: []
content_hash: cc0f228c2ba214cc818c23fb7f266173fd25a868215c855d5ab656f0ed4eae9e
description:
  Pipeline que convierte una web app en un APK de Android usando Capacitor. Valida el entorno (ANDROID_HOME, Java 17+), construye el bundle web si no existe dist/, inicializa Capacitor si no está configurado, añade la plataforma Android, sincroniza y compila el APK con Gradle. El APK final queda en el directorio raíz de la web app.

example:
  ```bash
  # Build con defaults (app-id y app-name desde package.json)
  ./bash/functions/pipelines/capacitor_build_apk.sh ~/projects/my-web-app

  # Build especificando app-id y app-name
  ./bash/functions/pipelines/capacitor_build_apk.sh ~/projects/my-web-app \
    --app-id com.miempresa.miapp \
    --app-name "Mi Aplicación"
  ```

notes:
  El pipeline usa `set -euo pipefail` — cualquier fallo detiene la ejecución inmediatamente.

  El APK generado es un **debug build**, apto para desarrollo y pruebas. Para publicar en Play Store se necesita un release build firmado (`assembleRelease` con un keystore).

  `install_android_sdk_bash_infra` se referencia como dependencia previa: el usuario debe haberlo ejecutado (o haber instalado el SDK manualmente) antes de invocar este pipeline.

  La detección del `app_name` desde `package.json` usa `node -e` inline, lo que requiere que Node.js esté disponible. Si el campo `name` no existe en el JSON, se usa el valor por defecto `app`.

  Para instalar el APK en un dispositivo Android conectado por USB (con depuración USB activada):

  ```bash
  adb install <web_app_dir>/<app_name>.apk
  ```

documentation:
  ## Flujo

  1. **Validación** — verifica que `web_app_dir` existe, tiene `package.json`, que `ANDROID_HOME` está seteado (o sourcea `$HOME/android-sdk/env.sh`) y que Java 17+ está disponible.
  2. **Build web** — si no existe `dist/`, ejecuta `pnpm build` en el directorio de la app.
  3. **Init Capacitor** — si no existe `capacitor.config.ts`, instala `@capacitor/core`, `@capacitor/cli` y `@capacitor/android` via npm y genera el archivo de configuración con el `appId`, `appName` y `webDir: dist`.
  4. **Add Android** — si no existe el directorio `android/`, ejecuta `npx cap add android`.
  5. **Sync** — ejecuta `npx cap sync android` para copiar los assets web al proyecto Android.
  6. **Build APK** — ejecuta `./gradlew assembleDebug` desde `android/`; si falla sale con exit 1.
  7. **Copia APK** — copia `android/app/build/outputs/apk/debug/app-debug.apk` a `<web_app_dir>/<app_name>.apk`.
  8. **Resultado** — imprime la ruta del APK y su tamaño en MB.

  ## Requisitos

  - **Node.js** y **pnpm** disponibles en PATH
  - **Java 17+** disponible en PATH
  - **Android SDK** instalado: `ANDROID_HOME` seteado, o bien `$HOME/android-sdk/env.sh` existente (generado por `install_android_sdk`)
  - **Gradle wrapper** presente en el directorio `android/` (generado por `cap add android`)

code:
  #!/usr/bin/env bash
  # capacitor_build_apk
  # -------------------
  # Pipeline que convierte una web app buildeada en un APK de Android usando Capacitor.
  # Asume que el Android SDK está instalado (via install_android_sdk o manualmente).
  #
  # USO:
  #   ./capacitor_build_apk.sh <web_app_dir> [--app-id com.example.app] [--app-name "My App"]
  #
  # ARGUMENTOS:
  #   web_app_dir         Directorio de la web app (debe contener package.json)
  #   --app-id            ID de la app Android (default: com.fnregistry.app)
  #   --app-name          Nombre visible de la app (default: name de package.json)
  #
  # REQUISITOS:
  #   - Node.js + pnpm instalados en PATH
  #   - Java 17+ instalado en PATH
  #   - Android SDK: ANDROID_HOME seteado o $HOME/android-sdk/env.sh disponible

  set -euo pipefail

  # ---------------------------------------------------------------------------
  # Parseo de argumentos
  # ---------------------------------------------------------------------------

  WEB_APP_DIR=""
  APP_ID="com.fnregistry.app"
  APP_NAME=""

  while [[ $# -gt 0 ]]; do
      case "$1" in
          --app-id)
              APP_ID="$2"
              shift 2
              ;;
          --app-name)
              APP_NAME="$2"
              shift 2
              ;;
          -*)
              echo "[capacitor_build_apk] ERROR: argumento desconocido: $1" >&2
              echo "USO: $0 <web_app_dir> [--app-id com.example.app] [--app-name \"My App\"]" >&2
              exit 1
              ;;
          *)
              WEB_APP_DIR="$1"
              shift
              ;;
      esac
  done

  if [[ -z "$WEB_APP_DIR" ]]; then
      echo "[capacitor_build_apk] ERROR: web_app_dir es obligatorio." >&2
      echo "USO: $0 <web_app_dir> [--app-id com.example.app] [--app-name \"My App\"]" >&2
      exit 1
  fi

  # ---------------------------------------------------------------------------
  # 1. Validación
  # ---------------------------------------------------------------------------

  echo "[capacitor_build_apk] Validando entorno..."

  # Verificar que web_app_dir existe y tiene package.json
  if [[ ! -d "$WEB_APP_DIR" ]]; then
      echo "[capacitor_build_apk] ERROR: directorio no existe: $WEB_APP_DIR" >&2
      exit 1
  fi

  if [[ ! -f "$WEB_APP_DIR/package.json" ]]; then
      echo "[capacitor_build_apk] ERROR: no se encontró package.json en $WEB_APP_DIR" >&2
      exit 1
  fi

  # Resolver app name desde package.json si no se pasó
  if [[ -z "$APP_NAME" ]]; then
      APP_NAME=$(node -e "const p = require('$WEB_APP_DIR/package.json'); process.stdout.write(p.name || 'app');" 2>/dev/null || echo "app")
      echo "[capacitor_build_apk] App name detectado desde package.json: $APP_NAME"
  fi

  # Verificar ANDROID_HOME o sourcea env.sh
  if [[ -z "${ANDROID_HOME:-}" ]]; then
      ANDROID_ENV="$HOME/android-sdk/env.sh"
      if [[ -f "$ANDROID_ENV" ]]; then
          echo "[capacitor_build_apk] ANDROID_HOME no seteado, sourceando $ANDROID_ENV ..."
          # shellcheck source=/dev/null
          source "$ANDROID_ENV"
      else
          echo "[capacitor_build_apk] ERROR: ANDROID_HOME no está seteado y no se encontró $ANDROID_ENV" >&2
          echo "  Instala el SDK con install_android_sdk o setea ANDROID_HOME manualmente." >&2
          exit 1
      fi
  fi

  echo "[capacitor_build_apk] ANDROID_HOME: $ANDROID_HOME"

  # Verificar Java 17+
  if ! command -v java &>/dev/null; then
      echo "[capacitor_build_apk] ERROR: java no está en PATH." >&2
      exit 1
  fi

  JAVA_VERSION=$(java -version 2>&1 | head -1 | grep -oP '(?<=version ")([0-9]+)' | head -1 || echo "0")
  if [[ "$JAVA_VERSION" -lt 17 ]]; then
      echo "[capacitor_build_apk] ERROR: se requiere Java 17+. Versión detectada: $JAVA_VERSION" >&2
      exit 1
  fi

  echo "[capacitor_build_apk] Java $JAVA_VERSION detectado."

  # ---------------------------------------------------------------------------
  # 2. Build web (si no existe dist/)
  # ---------------------------------------------------------------------------

  if [[ ! -d "$WEB_APP_DIR/dist" ]]; then
      echo "[capacitor_build_apk] No se encontró dist/, ejecutando pnpm build..."
      (cd "$WEB_APP_DIR" && pnpm build)
      echo "[capacitor_build_apk] Build web completado."
  else
      echo "[capacitor_build_apk] dist/ ya existe, omitiendo build web."
  fi

  # ---------------------------------------------------------------------------
  # 3. Init Capacitor (si no existe capacitor.config.ts)
  # ---------------------------------------------------------------------------

  if [[ ! -f "$WEB_APP_DIR/capacitor.config.ts" ]]; then
      echo "[capacitor_build_apk] Instalando dependencias de Capacitor..."
      (cd "$WEB_APP_DIR" && npm install @capacitor/core @capacitor/cli @capacitor/android)

      echo "[capacitor_build_apk] Generando capacitor.config.ts..."
      cat > "$WEB_APP_DIR/capacitor.config.ts" <<CAPCONFIG
  import type { CapacitorConfig } from '@capacitor/cli';

  const config: CapacitorConfig = {
    appId: '${APP_ID}',
    appName: '${APP_NAME}',
    webDir: 'dist',
    server: { androidScheme: 'https' }
  };

  export default config;
  CAPCONFIG

      echo "[capacitor_build_apk] capacitor.config.ts generado."
  else
      echo "[capacitor_build_apk] capacitor.config.ts ya existe, omitiendo init."
  fi

  # ---------------------------------------------------------------------------
  # 4. Add Android (si no existe el directorio android/)
  # ---------------------------------------------------------------------------

  if [[ ! -d "$WEB_APP_DIR/android" ]]; then
      echo "[capacitor_build_apk] Añadiendo plataforma Android..."
      (cd "$WEB_APP_DIR" && npx cap add android)
      echo "[capacitor_build_apk] Plataforma Android añadida."
  else
      echo "[capacitor_build_apk] Directorio android/ ya existe, omitiendo cap add."
  fi

  # ---------------------------------------------------------------------------
  # 5. Sync
  # ---------------------------------------------------------------------------

  echo "[capacitor_build_apk] Sincronizando assets web con Android..."
  (cd "$WEB_APP_DIR" && npx cap sync android)
  echo "[capacitor_build_apk] Sync completado."

  # ---------------------------------------------------------------------------
  # 6. Build APK
  # ---------------------------------------------------------------------------

  echo "[capacitor_build_apk] Compilando APK con Gradle..."
  if ! (cd "$WEB_APP_DIR/android" && ./gradlew assembleDebug); then
      echo "[capacitor_build_apk] ERROR: Gradle falló. Revisa los logs anteriores." >&2
      exit 1
  fi

  APK_SOURCE="$WEB_APP_DIR/android/app/build/outputs/apk/debug/app-debug.apk"

  if [[ ! -f "$APK_SOURCE" ]]; then
      echo "[capacitor_build_apk] ERROR: Gradle terminó sin error pero no se encontró el APK en $APK_SOURCE" >&2
      exit 1
  fi

  # ---------------------------------------------------------------------------
  # 7. Copia APK al directorio raíz
  # ---------------------------------------------------------------------------

  APK_DEST="$WEB_APP_DIR/${APP_NAME}.apk"
  cp "$APK_SOURCE" "$APK_DEST"

  # ---------------------------------------------------------------------------
  # 8. Resultado
  # ---------------------------------------------------------------------------

  APK_SIZE_BYTES=$(stat -c%s "$APK_DEST" 2>/dev/null || stat -f%z "$APK_DEST" 2>/dev/null || echo "0")
  APK_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $APK_SIZE_BYTES/1048576}")

  echo ""
  echo "---------------------------------------------------------------------"
  echo "APK generado: $APK_DEST"
  echo "Tamaño: ${APK_SIZE_MB} MB"
  echo ""
  echo "Para instalar en un dispositivo conectado por USB:"
  echo "  adb install '$APK_DEST'"
  echo "---------------------------------------------------------------------"

params_schema:
  {"params":[{"name":"web_app_dir","desc":"directorio raíz de la web app; debe contener package.json; si no existe dist/ se ejecuta pnpm build automáticamente"},{"name":"app_id","desc":"identificador de la app Android en formato reverse-DNS (default: com.fnregistry.app)"},{"name":"app_name","desc":"nombre visible de la app Android; si se omite, se lee del campo name de package.json"}],"output":"APK de debug en \u003cweb_app_dir\u003e/\u003capp_name\u003e.apk; imprime ruta y tamaño en MB al finalizar"}


id: export_analysis_pdfs_bash_pipelines
name: export_analysis_pdfs
kind: pipeline
lang: bash
domain: pipelines
version: 1.0.0
purity: impure
signature: export_analysis_pdfs(nombre: string, [pattern: string]) -> void
tags: ["pipeline","jupyter","pdf","export","nbconvert","launcher"]
uses_functions: ["assert_command_exists_bash_shell","install_nbconvert_bash_infra","notebook_to_pdf_bash_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/pipelines/export_analysis_pdfs.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 547c15c27db0fa782bf3a11e72024e9a2634652f25e17f5790cb3378f3fc39bd
description:
  Exporta todos los notebooks de un analisis Jupyter a PDF. Instala nbconvert y playwright automaticamente si no estan presentes.

example:
  ```bash
  # Exportar todos los notebooks de un analisis
  ./export_analysis_pdfs.sh finanzas

  # Con pattern especifico
  ./export_analysis_pdfs.sh ml "notebooks/01_*.ipynb"

  # Via fn run
  fn run export_analysis_pdfs finanzas
  fn run export_analysis_pdfs ml "notebooks/01_*.ipynb"
  ```

notes:
  El analysis debe existir previamente en `analysis/{nombre}/` con un venv inicializado. Los PDFs se generan en `analysis/{nombre}/notebooks/pdf/` por defecto. El pipeline usa `set -euo pipefail` — cualquier fallo detiene la ejecucion.

documentation:
  ## Flujo

  1. `assert_command_exists uv` — verifica que uv esta disponible
  2. `install_nbconvert` — instala nbconvert y playwright con chromium (idempotente)
  3. `notebook_to_pdf` — convierte notebooks al patron indicado a PDF en `notebooks/pdf/`

code:
  #!/usr/bin/env bash
  # export_analysis_pdfs
  # ---------------------
  # Pipeline que exporta todos los notebooks de un analisis a PDF.
  # Compone: assert_command_exists + install_nbconvert + notebook_to_pdf
  #
  # USO:
  #   ./export_analysis_pdfs.sh <nombre_analysis> [pattern]
  #
  # EJEMPLOS:
  #   ./export_analysis_pdfs.sh finanzas
  #   ./export_analysis_pdfs.sh ml "notebooks/01_*.ipynb"

  set -euo pipefail

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

  # Source funciones atomicas
  source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/install_nbconvert.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/notebook_to_pdf.sh"

  # ── Argumentos ──────────────────────────────────────────────

  NOMBRE="${1:-}"
  if [ -z "$NOMBRE" ]; then
      echo "Uso: $0 <nombre_analysis> [pattern]" >&2
      echo "  Ejemplo: $0 finanzas" >&2
      echo "  Ejemplo: $0 ml 'notebooks/01_*.ipynb'" >&2
      exit 1
  fi
  shift
  PATTERN="${1:-notebooks/*.ipynb}"

  ANALYSIS_DIR="${REGISTRY_ROOT}/analysis/${NOMBRE}"

  # Verificar que el analysis existe
  if [ ! -d "$ANALYSIS_DIR" ]; then
      echo "Error: analysis '${NOMBRE}' no existe en ${ANALYSIS_DIR}" >&2
      exit 1
  fi

  echo ""
  echo "════════════════════════════════════════════════════════════"
  echo "  EXPORT ANALYSIS PDFs: ${NOMBRE}"
  echo "  Directorio: ${ANALYSIS_DIR}"
  echo "════════════════════════════════════════════════════════════"
  echo ""

  # ── 1. Verificar herramientas ───────────────────────────────

  echo "[1/3] Verificando herramientas..."
  assert_command_exists uv
  echo "  OK"

  # ── 2. Instalar nbconvert + playwright ──────────────────────

  echo "[2/3] Instalando dependencias de exportacion..."
  install_nbconvert "$ANALYSIS_DIR"
  echo "  OK"

  # ── 3. Convertir notebooks a PDF ────────────────────────────

  echo "[3/3] Convirtiendo notebooks a PDF..."
  notebook_to_pdf "$ANALYSIS_DIR" "$PATTERN"

  # ── Resumen ─────────────────────────────────────────────────

  echo ""
  echo "════════════════════════════════════════════════════════════"
  echo "  EXPORT COMPLETADO"
  echo "════════════════════════════════════════════════════════════"

params_schema:
  {"params":[{"name":"nombre","desc":"nombre del análisis en analysis/{nombre}/"},{"name":"pattern","desc":"glob de notebooks a exportar (default: notebooks/*.ipynb)"}],"output":"sin salida directa; genera PDFs en notebooks/pdf/"}


id: gitea_init_app_bash_pipelines
name: gitea_init_app
kind: pipeline
lang: bash
domain: pipelines
version: 1.0.0
purity: impure
signature: gitea_init_app(directory: string, owner: string, name: string, private: string) -> string
tags: ["gitea","git","pipeline","repo","create","push","launcher","infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/pipelines/gitea_init_app.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: 6fad8d2b0ff043b9697e43304b184c7f85d64cdb555bf372d8ccdaaeea4a89df
description:
  Pipeline que crea un repositorio en Gitea, sube el directorio local y añade a egutierrez como colaborador admin. Compone gitea_create_repo → gitea_push_directory → gitea_add_collaborator.

uses_functions:
  ["gitea_create_repo_bash_infra","gitea_push_directory_bash_infra","gitea_add_collaborator_bash_infra"]

example:
  ```bash
  export GITEA_URL="$(pass agentes/gitea-url)"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Crear repo con nombre inferido del directorio
  bash bash/functions/pipelines/gitea_init_app.sh /home/lucas/myapp myorg

  # Nombre explícito y repo privado
  bash bash/functions/pipelines/gitea_init_app.sh /home/lucas/myapp myorg my-custom-name true

  # Con flags
  bash bash/functions/pipelines/gitea_init_app.sh \
      --directory /home/lucas/myapp \
      --owner myorg \
      --name my-app \
      --private true
  ```

notes:
  - Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
  - Si el repo ya existe (409), el pipeline continúa con el push y añade el colaborador.
  - El colaborador `egutierrez` es fijo en el pipeline — para variarlo usar las funciones individuales.
  - La URL del repo se imprime a stdout al finalizar.

documentation:
  ## Pasos del pipeline

  1. `gitea_create_repo owner name private` — crea el repo (idempotente si ya existe)
  2. `gitea_push_directory directory owner repo` — inicializa git y hace push del directorio
  3. `gitea_add_collaborator owner repo egutierrez admin` — añade colaborador con permisos admin

code:
  #!/usr/bin/env bash
  # Pipeline: gitea_init_app — Crea repo en Gitea, sube directorio y añade colaborador egutierrez
  set -euo pipefail

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  source "$SCRIPT_DIR/../infra/gitea_create_repo.sh"
  source "$SCRIPT_DIR/../infra/gitea_push_directory.sh"
  source "$SCRIPT_DIR/../infra/gitea_add_collaborator.sh"

  main() {
      local directory=""
      local owner=""
      local name=""
      local private="false"

      # Parsear argumentos
      while [[ $# -gt 0 ]]; do
          case "$1" in
              --directory) directory="$2"; shift 2 ;;
              --owner)     owner="$2";     shift 2 ;;
              --name)      name="$2";      shift 2 ;;
              --private)   private="$2";   shift 2 ;;
              *)
                  # Argumentos posicionales: directory owner [name] [private]
                  if [[ -z "$directory" ]]; then
                      directory="$1"
                  elif [[ -z "$owner" ]]; then
                      owner="$1"
                  elif [[ -z "$name" ]]; then
                      name="$1"
                  else
                      private="$1"
                  fi
                  shift
                  ;;
          esac
      done

      if [[ -z "$directory" || -z "$owner" ]]; then
          echo "gitea_init_app: uso: gitea_init_app <directory> <owner> [name] [private]" >&2
          echo "gitea_init_app: o con flags: --directory <dir> --owner <owner> [--name <name>] [--private true]" >&2
          return 1
      fi

      # Inferir nombre del repo desde basename del directorio si no se especificó
      if [[ -z "$name" ]]; then
          name=$(basename "$directory")
          echo "gitea_init_app: nombre inferido del directorio: '$name'" >&2
      fi

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_init_app: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_init_app: GITEA_TOKEN no está seteado" >&2
          return 1
      fi

      echo "gitea_init_app: iniciando pipeline para '$owner/$name'..." >&2
      echo "gitea_init_app: directorio fuente: '$directory'" >&2

      # Paso 1: Crear repo
      echo "gitea_init_app: [1/3] creando repositorio..." >&2
      gitea_create_repo "$owner" "$name" "$private" "" > /dev/null

      # Paso 2: Subir directorio
      echo "gitea_init_app: [2/3] subiendo directorio al repositorio..." >&2
      gitea_push_directory "$directory" "$owner" "$name"

      # Paso 3: Añadir colaborador egutierrez con permisos admin
      echo "gitea_init_app: [3/3] añadiendo colaborador egutierrez..." >&2
      gitea_add_collaborator "$owner" "$name" "egutierrez" "admin"

      echo "gitea_init_app: pipeline completado — ${GITEA_URL}/${owner}/${name}" >&2
      echo "${GITEA_URL}/${owner}/${name}"
  }

  main "$@"

params_schema:
  {"params":[{"name":"directory","desc":"ruta al directorio local a subir como repositorio"},{"name":"owner","desc":"usuario u organización en Gitea que será propietaria del repo"},{"name":"name","desc":"nombre del repositorio (opcional: se infiere del basename del directorio)"},{"name":"private","desc":"si el repo debe ser privado, 'true' o 'false' (default: false)"}],"output":"URL del repositorio creado en Gitea"}


id: init_jupyter_analysis_bash_pipelines
name: init_jupyter_analysis
kind: pipeline
lang: bash
domain: pipelines
version: 1.0.0
purity: impure
signature: init_jupyter_analysis(nombre: string, [...paquetes_extra: string]) -> void
tags: ["jupyter","analysis","setup","pipeline","bash","launcher"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/pipelines/init_jupyter_analysis.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 9adee852b86ced3705792530a50eebe4a7770de3c37002fc94b8a33dfc77a674
description:
  Inicializa un analisis Jupyter completo en analysis/{nombre}/ con venv, paquetes, launcher, MCP y reglas para agentes Claude. Acepta paquetes extra opcionales.

uses_functions:
  ["assert_command_exists_bash_shell","find_free_port_bash_shell","init_uv_venv_bash_infra","uv_add_packages_bash_infra","write_jupyter_launcher_bash_infra","write_mcp_jupyter_config_bash_infra","write_claude_jupyter_rules_bash_infra","write_jupyter_registry_kernel_bash_infra"]

example:
  ```bash
  # Analisis basico
  ./init_jupyter_analysis.sh finanzas

  # Con paquetes extra
  ./init_jupyter_analysis.sh duckdb polars duckdb
  ./init_jupyter_analysis.sh ml scikit-learn torch

  # Via fn run
  fn run init_jupyter_analysis finanzas
  fn run init_jupyter_analysis ml scikit-learn torch
  ```

notes:
  Cada analisis es independiente (propio venv, propio Jupyter, propio MCP). Mismo patron que `apps/` pero para exploraciones no reutilizables.

  El pipeline usa `set -euo pipefail` — cualquier fallo detiene la ejecucion.

  Paquetes base siempre incluidos: jupyter, jupyterlab, jupyter-collaboration, jupyter-mcp-server, pandas, numpy, matplotlib. Los paquetes extra se añaden a estos.

documentation:
  ## Flujo

  1. `assert_command_exists` — verifica que uv o python3 estan disponibles
  2. Crea estructura `analysis/{nombre}/notebooks/` y `analysis/{nombre}/data/`
  3. `init_uv_venv` — crea venv en `analysis/{nombre}/.venv/`
  4. `uv_add_packages` — instala jupyter, jupyterlab, jupyter-collaboration, jupyter-mcp-server, pandas, numpy, matplotlib + extras
  5. `write_jupyter_launcher` — genera `run-jupyter-lab.sh` con modo colaborativo
  6. `find_free_port` + `write_mcp_jupyter_config` — detecta puerto libre y genera `.mcp.json`
  7. `write_claude_jupyter_rules` — genera `.claude/CLAUDE.md` con reglas de agente
  8. `write_jupyter_registry_kernel` — genera IPython startup con `fn_query`, `fn_search`, `fn_code` y acceso a `python/functions/`

code:
  #!/usr/bin/env bash
  # init_jupyter_analysis
  # ----------------------
  # Inicializa un analisis Jupyter completo en analysis/{nombre}/.
  # Compone: assert_command_exists + find_free_port + init_uv_venv +
  #          uv_add_packages + write_jupyter_launcher +
  #          write_mcp_jupyter_config + write_claude_jupyter_rules +
  #          write_jupyter_registry_kernel
  #
  # USO:
  #   ./init_jupyter_analysis.sh <nombre> [paquetes_extra...]
  #
  # EJEMPLOS:
  #   ./init_jupyter_analysis.sh finanzas
  #   ./init_jupyter_analysis.sh duckdb polars duckdb
  #   ./init_jupyter_analysis.sh ml scikit-learn torch

  set -euo pipefail

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

  # Source funciones atomicas
  source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
  source "$REGISTRY_ROOT/bash/functions/shell/find_free_port.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/init_uv_venv.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/uv_add_packages.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/write_jupyter_launcher.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/write_mcp_jupyter_config.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/write_claude_jupyter_rules.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/write_jupyter_registry_kernel.sh"

  # ── Argumentos ──────────────────────────────────────────────

  NOMBRE="${1:-}"
  if [ -z "$NOMBRE" ]; then
      echo "Uso: $0 <nombre> [paquetes_extra...]" >&2
      echo "  Ejemplo: $0 finanzas polars" >&2
      exit 1
  fi
  shift
  EXTRA_PACKAGES=("$@")

  ANALYSIS_DIR="${REGISTRY_ROOT}/analysis/${NOMBRE}"

  echo ""
  echo "════════════════════════════════════════════════════════════"
  echo "  INIT JUPYTER ANALYSIS: ${NOMBRE}"
  echo "  Directorio: ${ANALYSIS_DIR}"
  echo "════════════════════════════════════════════════════════════"
  echo ""

  # ── 1. Verificar herramientas ───────────────────────────────

  echo "[1/8] Verificando herramientas..."
  assert_command_exists uv || assert_command_exists python3
  echo "  OK"

  # ── 2. Crear estructura de carpetas ─────────────────────────

  echo "[2/8] Creando estructura..."
  mkdir -p "$ANALYSIS_DIR/notebooks" "$ANALYSIS_DIR/data"
  echo "  ${ANALYSIS_DIR}/notebooks/"
  echo "  ${ANALYSIS_DIR}/data/"

  # ── 3. Crear venv ───────────────────────────────────────────

  echo "[3/8] Inicializando venv..."
  venv_path=$(init_uv_venv "$ANALYSIS_DIR")
  echo "  $venv_path"

  # ── 4. Instalar paquetes ────────────────────────────────────

  echo "[4/8] Instalando paquetes..."
  BASE_PACKAGES=(jupyter jupyterlab jupyter-collaboration jupyter-mcp-server pandas numpy matplotlib)
  ALL_PACKAGES=("${BASE_PACKAGES[@]}" "${EXTRA_PACKAGES[@]}")
  uv_add_packages "$ANALYSIS_DIR" "${ALL_PACKAGES[@]}"
  echo "  Instalados: ${ALL_PACKAGES[*]}"

  # ── 5. Generar launcher ─────────────────────────────────────

  echo "[5/8] Generando launcher..."
  launcher=$(write_jupyter_launcher "$ANALYSIS_DIR")
  echo "  $launcher"

  # ── 6. Configurar MCP ───────────────────────────────────────

  echo "[6/8] Configurando MCP..."
  port=$(find_free_port 8888 8899)
  mcp_config=$(write_mcp_jupyter_config "$ANALYSIS_DIR" "$port")
  echo "  $mcp_config (puerto: $port)"

  # ── 7. Reglas para agentes ──────────────────────────────────

  echo "[7/8] Escribiendo reglas Claude..."
  rules=$(write_claude_jupyter_rules "$ANALYSIS_DIR")
  echo "  $rules"

  # ── 8. Kernel startup con acceso al registry ────────────────

  echo "[8/8] Configurando kernel con acceso al registry..."
  kernel_startup=$(write_jupyter_registry_kernel "$ANALYSIS_DIR")
  echo "  $kernel_startup"

  # ── Resumen ─────────────────────────────────────────────────

  echo ""
  echo "════════════════════════════════════════════════════════════"
  echo "  ANALISIS '${NOMBRE}' LISTO"
  echo "════════════════════════════════════════════════════════════"
  echo ""
  echo "  Pasos siguientes:"
  echo ""
  echo "  1. En otra terminal:"
  echo "     cd ${ANALYSIS_DIR} && ./run-jupyter-lab.sh"
  echo ""
  echo "  2. Abrir Claude en el analisis:"
  echo "     cd ${ANALYSIS_DIR} && claude"
  echo ""
  echo "  3. Abrir en navegador: http://localhost:${port}"
  echo ""
  echo "  FN_REGISTRY_ROOT=${REGISTRY_ROOT}"
  echo ""

params_schema:
  {"params":[{"name":"nombre","desc":"nombre del análisis a crear"},{"name":"paquetes_extra","desc":"paquetes Python adicionales a instalar (variadic, opcional)"}],"output":"sin salida directa; estructura completa en analysis/{nombre}/"}


id: setup_metabase_volume_bash_pipelines
name: setup_metabase_volume
kind: pipeline
lang: bash
domain: pipelines
version: 1.0.0
purity: impure
tags: ["metabase","docker","setup","launcher","pipeline","bash","infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/pipelines/setup_metabase_volume.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 1ef3a6f96c128f50b07f048588f672cf12c2efe339d2bb00438ab7aa0054674f
signature:
  setup_metabase_volume([registry_db_path: string], [container_name: string], [dest_path: string]) -> void

description:
  Copia registry.db al contenedor Docker de Metabase verificando existencia del archivo, disponibilidad de docker, estado del contenedor y coincidencia de tamaños. Todos los argumentos son opcionales con defaults razonables.

uses_functions:
  ["assert_file_exists_bash_shell","assert_command_exists_bash_shell","assert_docker_container_running_bash_infra","docker_cp_file_bash_infra"]

example:
  ```bash
  # Con defaults
  ./functions/pipelines/setup_metabase_volume.sh

  # Con argumentos explícitos
  ./functions/pipelines/setup_metabase_volume.sh \
    /home/lucas/fn_registry/registry.db \
    metabase \
    /registry.db
  ```

notes:
  El pipeline usa `set -euo pipefail` — cualquier fallo en una función individual detiene la ejecución.

  Las funciones individuales se sourcean desde sus rutas en el registry, relativas a `REGISTRY_ROOT` detectado automáticamente desde la ubicación del script.

  Defaults:
  - `REGISTRY_DB_PATH`: `/home/lucas/fn_registry/registry.db`
  - `CONTAINER_NAME`: `metabase`
  - `DEST_PATH`: `/registry.db`

  Nota de persistencia: `docker cp` copia al contenedor en ejecución. Si el contenedor se reinicia, el archivo se pierde. Para persistencia real, montar el directorio como volumen en docker-compose:

  ```yaml
  volumes:
    - /home/lucas/fn_registry:/fn_registry:ro
  ```

  Y usar `--registry-db-path /fn_registry/registry.db`.

documentation:
  ## Flujo

  1. `assert_file_exists` — verifica que `registry.db` existe localmente y obtiene su tamaño
  2. `assert_command_exists` — verifica que `docker` está disponible en PATH
  3. `assert_docker_container_running` — verifica que el contenedor destino está activo; si falla, muestra lista de contenedores activos
  4. `docker_cp_file` — ejecuta `docker cp` y verifica que los tamaños local y remoto coinciden

code:
  #!/usr/bin/env bash
  # setup_metabase_volume
  # ---------------------
  # Copia registry.db al contenedor Docker de Metabase.
  # Compone: assert_file_exists + assert_command_exists +
  #          assert_docker_container_running + docker_cp_file
  #
  # USO:
  #   ./setup_metabase_volume.sh [REGISTRY_DB_PATH] [CONTAINER_NAME] [DEST_PATH]
  #
  # ARGUMENTOS (opcionales, con defaults):
  #   REGISTRY_DB_PATH  Ruta local al registry.db
  #                     Default: /home/lucas/fn_registry/registry.db
  #   CONTAINER_NAME    Nombre del contenedor Docker de Metabase
  #                     Default: metabase
  #   DEST_PATH         Ruta destino dentro del contenedor
  #                     Default: /registry.db

  set -euo pipefail

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

  source "$REGISTRY_ROOT/bash/functions/shell/assert_file_exists.sh"
  source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/assert_docker_container_running.sh"
  source "$REGISTRY_ROOT/bash/functions/infra/docker_cp_file.sh"

  REGISTRY_DB_PATH="${1:-/home/lucas/fn_registry/registry.db}"
  CONTAINER_NAME="${2:-metabase}"
  DEST_PATH="${3:-/registry.db}"

  echo "[setup_metabase_volume] Configuracion:"
  echo "  registry.db local : $REGISTRY_DB_PATH"
  echo "  contenedor Docker  : $CONTAINER_NAME"
  echo "  ruta en contenedor : $DEST_PATH"
  echo ""

  # 1. Verificar archivo local
  local_size=$(assert_file_exists "$REGISTRY_DB_PATH")
  echo "[setup_metabase_volume] Archivo local encontrado: ${local_size} bytes"

  # 2. Verificar que docker esta disponible
  assert_command_exists docker
  echo "[setup_metabase_volume] docker disponible en PATH."

  # 3. Verificar que el contenedor esta corriendo
  if ! assert_docker_container_running "$CONTAINER_NAME"; then
      echo "" >&2
      echo "Contenedores activos:" >&2
      docker ps --format "  {{.Names}}\t{{.Status}}\t{{.Image}}" >&2
      echo "" >&2
      echo "Si el contenedor se llama diferente, pasa el nombre como segundo argumento:" >&2
      echo "  ./setup_metabase_volume.sh $REGISTRY_DB_PATH <nombre_contenedor>" >&2
      exit 1
  fi
  echo "[setup_metabase_volume] Contenedor '$CONTAINER_NAME' encontrado y activo."

  # 4. Copiar archivo y verificar tamaños
  echo "[setup_metabase_volume] Copiando $REGISTRY_DB_PATH -> ${CONTAINER_NAME}:${DEST_PATH} ..."
  result=$(docker_cp_file "$REGISTRY_DB_PATH" "$CONTAINER_NAME" "$DEST_PATH")

  remote_size=$(echo "$result" | grep -o '"remote_size":[0-9]*' | cut -d: -f2)
  echo "[setup_metabase_volume] OK Copia completada y verificada."
  echo "[setup_metabase_volume] Tamanio: ${local_size} bytes (local) = ${remote_size} bytes (remoto)"

  echo ""
  echo "---------------------------------------------------------------------"
  echo "registry.db disponible en el contenedor como: $DEST_PATH"
  echo ""
  echo "Ahora ejecuta main.py con:"
  echo ""
  echo "  METABASE_ADMIN_PASSWORD=<password> \\"
  echo "  REGISTRY_DB_PATH=${DEST_PATH} \\"
  echo "  python apps/metabase_registry/main.py"
  echo ""
  echo "O bien:"
  echo ""
  echo "  python apps/metabase_registry/main.py \\"
  echo "    --admin-password <password> \\"
  echo "    --registry-db-path ${DEST_PATH}"
  echo "---------------------------------------------------------------------"

params_schema:
  {"params":[{"name":"registry_db_path","desc":"ruta a registry.db local (default: /home/lucas/fn_registry/registry.db)"},{"name":"container_name","desc":"nombre del contenedor Metabase (default: metabase)"},{"name":"dest_path","desc":"ruta destino dentro del contenedor (default: /registry.db)"}],"output":"sin salida directa; verifica transferencia con coincidencia de tamaños"}


id: assert_command_exists_bash_shell
name: assert_command_exists
kind: function
lang: bash
domain: shell
version: 1.0.0
purity: impure
signature: assert_command_exists(command_name: string) -> void
tags: ["assert","command","exists","validation","shell","bash","path"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/shell/assert_command_exists.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5c4e73149cf38e36946509c40ea013491b38c991beb820f767fa14edc66fd615
description:
  Verifica que un comando está disponible en el PATH. Sale con exit code 1 si no se encuentra, con mensaje a stderr.

example:
  ```bash
  source functions/shell/assert_command_exists.sh

  assert_command_exists docker
  assert_command_exists jq
  ```

notes:
  Usa `command -v` (POSIX) con redirección `&>/dev/null` para suprimir output. No produce nada a stdout en caso de éxito.

  Output limpio: void en éxito, mensaje a stderr en fallo.

code:
  # assert_command_exists
  # ---------------------
  # Verifica que un comando está disponible en el PATH.
  # No produce output a stdout.
  # Sale con exit code 1 si el comando no se encuentra.
  #
  # USO (sourced):
  #   source assert_command_exists.sh
  #   assert_command_exists docker

  assert_command_exists() {
      local command_name="$1"

      if ! command -v "$command_name" &>/dev/null; then
          echo "assert_command_exists: comando no encontrado en PATH: $command_name" >&2
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"command_name","desc":"nombre del comando a verificar en PATH"}],"output":"sin salida en éxito"}


id: assert_file_exists_bash_shell
name: assert_file_exists
kind: function
lang: bash
domain: shell
version: 1.0.0
purity: impure
signature: assert_file_exists(file_path: string) -> string
tags: ["assert","file","exists","validation","shell","bash"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/shell/assert_file_exists.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 4183e99894fa00ecc4c48143268e35993f5a109593be7edea88ad3b9473ac4b3
description:
  Verifica que un archivo existe en el filesystem. Imprime su tamaño en bytes a stdout. Sale con exit code 1 si el archivo no existe.

example:
  ```bash
  source functions/shell/assert_file_exists.sh

  size=$(assert_file_exists /home/lucas/fn_registry/registry.db)
  echo "Tamaño: $size bytes"
  ```

notes:
  La función se sourcea, no se ejecuta directamente. Usa `stat -c%s` para obtener el tamaño en bytes (compatible con GNU coreutils / Linux).

  Output limpio: solo el número de bytes a stdout. Los errores van a stderr.

  No usa `set -e` internamente — el caller controla el flujo con el exit code de retorno.

code:
  # assert_file_exists
  # ------------------
  # Verifica que un archivo existe en el filesystem.
  # Imprime su tamaño en bytes a stdout.
  # Sale con exit code 1 si el archivo no existe.
  #
  # USO (sourced):
  #   source assert_file_exists.sh
  #   size=$(assert_file_exists /ruta/al/archivo)

  assert_file_exists() {
      local file_path="$1"

      if [ ! -f "$file_path" ]; then
          echo "assert_file_exists: archivo no encontrado: $file_path" >&2
          return 1
      fi

      stat -c%s "$file_path"
  }

params_schema:
  {"params":[{"name":"file_path","desc":"ruta del archivo a verificar"}],"output":"tamaño del archivo en bytes"}


id: exit_with_status_bash_shell
name: exit_with_status
kind: function
lang: bash
domain: shell
version: 1.0.0
purity: pure
signature: exit_with_status(total_steps: int, ok_steps: int, failed_steps: int) -> int
tags: ["execution","status","exit-code","standard"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: bash/functions/shell/exit_with_status.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 3c6882702f826c950f90bf52e72130faf89681f0866e65e8653312e779a3075e
description:
  Calcula el exit code estandar (0=success, 1=failure, 2=partial) a partir de contadores de pasos. Si failed_steps=0 imprime 0 y sale con 0. Si ok_steps=0 imprime 1 y sale con 1. Si hay ambos imprime 2 y sale con 2.

example:
  ```bash
  source bash/functions/shell/exit_with_status.sh

  exit_with_status 5 5 0  # stdout: 0, exit code: 0
  exit_with_status 5 0 5  # stdout: 1, exit code: 1
  exit_with_status 5 3 2  # stdout: 2, exit code: 2
  ```

notes:
  Funcion pura: no realiza I/O de sistema, no modifica estado global, no lee variables de entorno. El argumento `total_steps` se recibe para completitud semantica pero la logica solo depende de `ok_steps` y `failed_steps`. El valor se imprime a stdout ademas de usarse como exit code, de modo que el caller puede capturarlo con `$(exit_with_status ...)` o evaluar directamente con `exit_with_status ... ; echo $?`.

code:
  #!/usr/bin/env bash
  # exit_with_status — calcula el exit code estandar a partir de contadores de pasos

  # exit_with_status <total_steps> <ok_steps> <failed_steps>
  #
  # Calcula el exit code estandar:
  #   0 — todos los pasos exitosos (failed_steps == 0)
  #   1 — todos los pasos fallaron (ok_steps == 0)
  #   2 — resultado parcial (hay ok y failed)
  #
  # Imprime el codigo a stdout y sale con ese codigo.
  exit_with_status() {
      local total_steps="$1"
      local ok_steps="$2"
      local failed_steps="$3"

      local code

      if [[ "$failed_steps" -eq 0 ]]; then
          code=0
      elif [[ "$ok_steps" -eq 0 ]]; then
          code=1
      else
          code=2
      fi

      echo "$code"
      return "$code"
  }

params_schema:
  {"params":[{"name":"total_steps","desc":"número total de pasos ejecutados"},{"name":"ok_steps","desc":"número de pasos exitosos"},{"name":"failed_steps","desc":"número de pasos fallidos"}],"output":"exit code: 0 (éxito), 1 (fallo), o 2 (parcial)"}


id: find_free_port_bash_shell
name: find_free_port
kind: function
lang: bash
domain: shell
version: 1.0.0
purity: impure
signature: find_free_port([start_port: int], [end_port: int]) -> int
tags: ["network","port","shell","utility"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/shell/find_free_port.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b89bacb32e1a6293e15b43b58a72c92587d3c5b30e1ac95cda12356948561e82
description:
  Busca el primer puerto TCP libre en un rango dado usando ss y lsof. Retorna el numero de puerto a stdout.

example:
  ```bash
  source find_free_port.sh
  port=$(find_free_port 8888 8899)
  echo "Puerto libre: $port"

  # Con defaults (8888-8899)
  port=$(find_free_port)
  ```

notes:
  Usa `ss -tln` como primer intento y `lsof` como fallback. Ambos deben confirmar que el puerto no esta en uso. Si ningun puerto esta libre en el rango, sale con exit code 1.

code:
  # find_free_port
  # ---------------
  # Busca el primer puerto libre en un rango dado.
  # Imprime el puerto encontrado a stdout.
  # Sale con exit code 1 si no encuentra ninguno.
  #
  # USO (sourced):
  #   source find_free_port.sh
  #   port=$(find_free_port 8888 8899)

  find_free_port() {
      local start="${1:-8888}"
      local end="${2:-8899}"

      for ((port=start; port<=end; port++)); do
          if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
             ! lsof -i:"$port" >/dev/null 2>&1; then
              echo "$port"
              return 0
          fi
      done

      echo "find_free_port: no se encontro puerto libre en rango ${start}-${end}" >&2
      return 1
  }

params_schema:
  {"params":[{"name":"start_port","desc":"puerto inicial del rango (default: 8888)"},{"name":"end_port","desc":"puerto final del rango (default: 8899)"}],"output":"número de puerto TCP libre"}


id: report_execution_json_bash_shell
name: report_execution_json
kind: function
lang: bash
domain: shell
version: 2.0.0
purity: pure
tags: ["execution","json","report","standard","shell","pure"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: bash/functions/shell/report_execution_json.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: d069da827fcca273c1d63cc45ddb2f3fccd4240f176f870a45843381f87fa1be
signature:
  report_execution_json(flow_name: string, status: string, exit_code: int, started_at: string, ended_at: string, duration_ms: int, steps_file: string) -> string

description:
  Genera un JSON de reporte de ejecucion siguiendo el estandar fn-registry (docs/execution_standard.md). Recibe los metadatos del flujo y un archivo TSV con resultados de pasos (columnas: name, action, status, elapsed_ms, output, error). Imprime el JSON completo a stdout. Usa jq si esta disponible, con fallback a printf. Funcion pura: sin efectos secundarios ni I/O adicional.

example:
  ```bash
  source bash/functions/shell/report_execution_json.sh

  # Crear archivo de pasos (TSV sin cabecera, 6 columnas)
  cat > /tmp/steps.tsv <<'EOF'
  check_db	command	ok	10	exists
  backup	command	ok	450	done
  push	command	error	2540		remote rejected
  EOF

  report_execution_json \
      "backup_db" "partial" 2 \
      "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 \
      /tmp/steps.tsv
  ```

  Output esperado:

  ```json
  {"name":"backup_db","status":"partial","exit_code":2,"started_at":"2026-04-01T02:00:00Z","ended_at":"2026-04-01T02:00:03Z","duration_ms":3000,"steps_total":3,"steps_ok":2,"steps_failed":1,"steps":[{"name":"check_db","action":"command","status":"ok","elapsed_ms":10,"output":"exists"},{"name":"backup","action":"command","status":"ok","elapsed_ms":450,"output":"done"},{"name":"push","action":"command","status":"error","elapsed_ms":2540,"error":"remote rejected"}]}
  ```

notes:
  Funcion pura: solo lee el archivo de pasos y escribe JSON a stdout, sin efectos secundarios ni I/O adicional mas alla de leer el steps_file.

  Formato TSV: 6 columnas separadas por tabulador real (sin cabecera): name, action, status (ok|error), elapsed_ms, output, error. Los campos output y error pueden estar vacios; se omiten del JSON si no tienen valor, siguiendo el estandar de output estructurado.

  El caller es responsable de calcular status (success|failure|partial) y exit_code (0|1|2) segun las reglas del estandar. Ver exit_with_status_bash_shell para esa logica.

  Compatibilidad dual: con jq construye el JSON de forma robusta (maneja caracteres especiales y saltos de linea en output/error). Sin jq, el fallback con printf escapa backslash, comillas dobles y caracteres de control basicos.

  Puede ejecutarse directamente: `bash report_execution_json.sh <args>`.

code:
  #!/usr/bin/env bash
  # report_execution_json — Genera un JSON de reporte de ejecucion siguiendo el estandar fn-registry.
  #
  # USO (sourced):
  #   source report_execution_json.sh
  #   report_execution_json "backup_db" "partial" 2 \
  #       "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 /tmp/steps.tsv
  #
  # USO (ejecutado directamente):
  #   bash report_execution_json.sh "backup_db" "partial" 2 \
  #       "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 /tmp/steps.tsv
  #
  # FORMATO steps_file (TSV sin cabecera, 6 columnas):
  #   name<TAB>action<TAB>status<TAB>elapsed_ms<TAB>output<TAB>error
  #   Los campos output y error pueden estar vacios.
  #   status valido: ok | error
  #
  # NOTA sobre IFS y tabs: bash trata tab como whitespace en IFS y colapsa
  # campos vacios consecutivos. Se usa 'cut -f N' para parsear cada columna
  # de forma correcta cuando hay campos vacios entre tabs.

  report_execution_json() {
      local flow_name="$1"
      local status="$2"
      local exit_code="$3"
      local started_at="$4"
      local ended_at="$5"
      local duration_ms="$6"
      local steps_file="$7"

      if [[ -z "$flow_name" || -z "$status" || -z "$exit_code" || \
            -z "$started_at" || -z "$ended_at" || -z "$duration_ms" || \
            -z "$steps_file" ]]; then
          echo "report_execution_json: uso: report_execution_json <flow_name> <status> <exit_code> <started_at> <ended_at> <duration_ms> <steps_file>" >&2
          return 1
      fi

      if [[ ! -f "$steps_file" ]]; then
          echo "report_execution_json: archivo de pasos no encontrado: $steps_file" >&2
          return 1
      fi

      if command -v jq >/dev/null 2>&1; then
          _report_execution_json_jq \
              "$flow_name" "$status" "$exit_code" \
              "$started_at" "$ended_at" "$duration_ms" "$steps_file"
      else
          _report_execution_json_printf \
              "$flow_name" "$status" "$exit_code" \
              "$started_at" "$ended_at" "$duration_ms" "$steps_file"
      fi
  }

  # --- Implementacion con jq ---

  _report_execution_json_jq() {
      local flow_name="$1" status="$2" exit_code="$3"
      local started_at="$4" ended_at="$5" duration_ms="$6" steps_file="$7"

      local steps_ok=0 steps_failed=0
      local steps_json="[]"

      while IFS= read -r line; do
          [[ -z "$line" ]] && continue

          local s_name s_action s_status s_elapsed s_output s_error
          s_name=$(printf '%s' "$line" | cut -f1)
          s_action=$(printf '%s' "$line" | cut -f2)
          s_status=$(printf '%s' "$line" | cut -f3)
          s_elapsed=$(printf '%s' "$line" | cut -f4)
          s_output=$(printf '%s' "$line" | cut -f5)
          s_error=$(printf '%s' "$line" | cut -f6)

          [[ -z "$s_name" ]] && continue

          local step_obj
          step_obj=$(jq -n \
              --arg   name     "$s_name" \
              --arg   action   "$s_action" \
              --arg   st       "$s_status" \
              --argjson ms     "${s_elapsed:-0}" \
              --arg   output   "$s_output" \
              --arg   error    "$s_error" \
              '{name: $name, action: $action, status: $st, elapsed_ms: $ms}
               + (if $output != "" then {output: $output} else {} end)
               + (if $error  != "" then {error:  $error}  else {} end)')

          steps_json=$(printf '%s' "$steps_json" | jq --argjson step "$step_obj" '. + [$step]')

          if [[ "$s_status" == "ok" ]]; then
              ((steps_ok++))
          else
              ((steps_failed++))
          fi
      done < "$steps_file"

      local steps_total=$(( steps_ok + steps_failed ))

      jq -n \
          --arg     name         "$flow_name" \
          --arg     st           "$status" \
          --argjson exit_code    "$exit_code" \
          --arg     started_at   "$started_at" \
          --arg     ended_at     "$ended_at" \
          --argjson duration_ms  "$duration_ms" \
          --argjson total        "$steps_total" \
          --argjson ok           "$steps_ok" \
          --argjson failed       "$steps_failed" \
          --argjson steps        "$steps_json" \
          '{
              name:         $name,
              status:       $st,
              exit_code:    $exit_code,
              started_at:   $started_at,
              ended_at:     $ended_at,
              duration_ms:  $duration_ms,
              steps_total:  $total,
              steps_ok:     $ok,
              steps_failed: $failed,
              steps:        $steps
          }'
  }

  # --- Implementacion con printf (fallback sin jq) ---

  _json_escape_rj() {
      local s="$1"
      s="${s//\\/\\\\}"
      s="${s//\"/\\\"}"
      s="${s//$'\n'/\\n}"
      s="${s//$'\r'/\\r}"
      s="${s//$'\t'/\\t}"
      printf '%s' "$s"
  }

  _report_execution_json_printf() {
      local flow_name="$1" status="$2" exit_code="$3"
      local started_at="$4" ended_at="$5" duration_ms="$6" steps_file="$7"

      local steps_ok=0 steps_failed=0
      local steps_parts=()

      while IFS= read -r line; do
          [[ -z "$line" ]] && continue

          local s_name s_action s_status s_elapsed s_output s_error
          s_name=$(printf '%s' "$line" | cut -f1)
          s_action=$(printf '%s' "$line" | cut -f2)
          s_status=$(printf '%s' "$line" | cut -f3)
          s_elapsed=$(printf '%s' "$line" | cut -f4)
          s_output=$(printf '%s' "$line" | cut -f5)
          s_error=$(printf '%s' "$line" | cut -f6)

          [[ -z "$s_name" ]] && continue

          local part
          part=$(printf '{"name":"%s","action":"%s","status":"%s","elapsed_ms":%s' \
              "$(_json_escape_rj "$s_name")" \
              "$(_json_escape_rj "$s_action")" \
              "$(_json_escape_rj "$s_status")" \
              "${s_elapsed:-0}")

          if [[ -n "$s_output" ]]; then
              part+=",\"output\":\"$(_json_escape_rj "$s_output")\""
          fi
          if [[ -n "$s_error" ]]; then
              part+=",\"error\":\"$(_json_escape_rj "$s_error")\""
          fi
          part+="}"

          steps_parts+=("$part")

          if [[ "$s_status" == "ok" ]]; then
              ((steps_ok++))
          else
              ((steps_failed++))
          fi
      done < "$steps_file"

      local steps_total=$(( steps_ok + steps_failed ))

      local steps_array="["
      local first=1
      for part in "${steps_parts[@]}"; do
          if [[ $first -eq 1 ]]; then
              steps_array+="$part"
              first=0
          else
              steps_array+=",$part"
          fi
      done
      steps_array+="]"

      printf '{"name":"%s","status":"%s","exit_code":%s,"started_at":"%s","ended_at":"%s","duration_ms":%s,"steps_total":%s,"steps_ok":%s,"steps_failed":%s,"steps":%s}\n' \
          "$(_json_escape_rj "$flow_name")" \
          "$(_json_escape_rj "$status")" \
          "$exit_code" \
          "$(_json_escape_rj "$started_at")" \
          "$(_json_escape_rj "$ended_at")" \
          "$duration_ms" \
          "$steps_total" \
          "$steps_ok" \
          "$steps_failed" \
          "$steps_array"
  }

  # Permitir ejecucion directa (no solo sourced)
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      report_execution_json "$@"
  fi

params_schema:
  {"params":[{"name":"flow_name","desc":"nombre del flujo ejecutado"},{"name":"status","desc":"estado final: success, failure, o partial"},{"name":"exit_code","desc":"código de salida: 0, 1, o 2"},{"name":"started_at","desc":"timestamp ISO8601 de inicio"},{"name":"ended_at","desc":"timestamp ISO8601 de fin"},{"name":"duration_ms","desc":"duración total en milisegundos"},{"name":"steps_file","desc":"archivo TSV con resultados de cada paso"}],"output":"JSON estructurado con metadatos de ejecución y array de pasos"}


id: run_steps_bash_shell
name: run_steps
kind: function
lang: bash
domain: shell
version: 1.0.0
purity: impure
signature: run_steps(yaml_file: string, [--strict]) -> string
tags: ["execution","yaml","runner","standard","pipeline"]
uses_functions: ["exit_with_status_bash_shell","report_execution_json_bash_shell"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/shell/run_steps.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: c633d74c155f5e8accd1bf4d9b22505e47ce005ad9445740721654fd87aa66aa
description:
  Ejecuta pasos de un YAML generico donde cada step tiene action=command. Lee el YAML con yq, ejecuta cada paso secuencialmente con timeout configurable, captura exit code y output, respeta continue_on_error, y al final reporta JSON estandar a stdout via report_execution_json. Sale con exit code 0 (success), 1 (failure) o 2 (partial). Con --strict mapea partial a failure.

example:
  ```yaml
  # /tmp/test_flow.yaml
  name: test_flow
  steps:
    - name: check
      action: command
      command: "echo hello"
    - name: list
      action: command
      command: "ls /tmp"
      continue_on_error: true
    - name: slow_step
      action: command
      command: "sleep 2"
      timeout_ms: 1000
      continue_on_error: true
  ```

  ```bash
  source bash/functions/shell/run_steps.sh

  run_steps /tmp/test_flow.yaml
  # Imprime JSON a stdout:
  # {
  #   "name": "test_flow",
  #   "status": "partial",
  #   "exit_code": 2,
  #   "started_at": "2026-04-01T10:00:00Z",
  #   "ended_at": "2026-04-01T10:00:01Z",
  #   "duration_ms": 1250,
  #   "steps_total": 3,
  #   "steps_ok": 2,
  #   "steps_failed": 1,
  #   "steps": [
  #     {"name": "check", "action": "command", "status": "ok", "elapsed_ms": 10, "output": "hello"},
  #     {"name": "list", "action": "command", "status": "ok", "elapsed_ms": 15},
  #     {"name": "slow_step", "action": "command", "status": "error", "elapsed_ms": 1001, "error": "timeout: comando excedio 1000ms"}
  #   ]
  # }
  # Sale con exit code 2 (partial)

  run_steps /tmp/test_flow.yaml --strict
  # Igual pero status="failure" y exit code=1
  ```

notes:
  Requiere `yq` (v4+, modo expression `-e`) y `jq` (o la implementacion printf de report_execution_json). Solo soporta `action: command` — otros actions se marcan como error del paso. El campo `timeout_ms` por defecto es 30000 (30s); si el comando excede el timeout, `timeout(1)` sale con exit code 124 y se registra el error correspondiente. El output del comando (stdout+stderr combinados) se sanitiza reemplazando tabuladores y saltos de linea por espacios antes de escribir al TSV temporal. Las funciones `exit_with_status` y `report_execution_json` se cargan via `source` desde el mismo directorio que `run_steps.sh`.

code:
  #!/usr/bin/env bash
  # run_steps — ejecuta pasos de un YAML generico (action=command)

  # run_steps <yaml_file> [--strict]
  #
  # Lee un YAML con la estructura:
  #   name: <run_name>
  #   steps:
  #     - name: <step_name>
  #       action: command
  #       command: "<shell_command>"
  #       continue_on_error: true|false   # opcional, default false
  #       timeout_ms: 30000               # opcional, default 30000
  #
  # Para cada paso de action=command:
  #   - Ejecuta el command con timeout (timeout_ms ms)
  #   - Captura exit code, stdout+stderr y elapsed time
  #   - Si falla y continue_on_error=false → aborta
  #   - Acumula resultados en TSV temporal
  #
  # Al final:
  #   - Llama a report_execution_json para generar JSON a stdout
  #   - Llama a exit_with_status para determinar el exit code
  #
  # --strict: mapea partial (2) a failure (1) en status y exit code
  #
  # Requiere: yq, jq (jq opcional si report_execution_json tiene fallback printf)
  run_steps() {
      local yaml_file="$1"
      local strict=0

      if [[ "${2:-}" == "--strict" ]]; then
          strict=1
      fi

      # --- validaciones previas ---
      if [[ -z "$yaml_file" ]]; then
          echo "run_steps: yaml_file requerido" >&2
          return 1
      fi

      if [[ ! -f "$yaml_file" ]]; then
          echo "run_steps: archivo '$yaml_file' no existe" >&2
          return 1
      fi

      if ! command -v yq &>/dev/null; then
          echo "run_steps: yq no encontrado en PATH" >&2
          return 1
      fi

      # --- cargar funciones del estandar ---
      local script_dir
      script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      # shellcheck source=bash/functions/shell/exit_with_status.sh
      source "$script_dir/exit_with_status.sh"
      # shellcheck source=bash/functions/shell/report_execution_json.sh
      source "$script_dir/report_execution_json.sh"

      # --- leer nombre del run ---
      local run_name
      run_name=$(yq e '.name // "unnamed"' "$yaml_file")

      # --- contar pasos ---
      local step_count
      step_count=$(yq e '.steps | length' "$yaml_file")

      if [[ -z "$step_count" || "$step_count" -eq 0 ]]; then
          echo "run_steps: el YAML no tiene steps" >&2
          return 1
      fi

      # --- archivo TSV temporal para acumular resultados ---
      # columnas: name<TAB>action<TAB>status<TAB>elapsed_ms<TAB>output<TAB>error
      local tsv_file
      tsv_file=$(mktemp /tmp/run_steps_XXXXXX.tsv)
      # shellcheck disable=SC2064
      trap "rm -f '$tsv_file'" EXIT

      local ok_steps=0
      local failed_steps=0
      local started_at
      started_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
      local t_run_start
      t_run_start=$(date +%s%3N)

      # --- iterar sobre cada paso ---
      local i
      for (( i=0; i<step_count; i++ )); do
          local step_name step_action step_command step_continue step_timeout_ms

          step_name=$(yq e ".steps[$i].name // \"step_$i\"" "$yaml_file")
          step_action=$(yq e ".steps[$i].action // \"command\"" "$yaml_file")
          step_command=$(yq e ".steps[$i].command // \"\"" "$yaml_file")
          step_continue=$(yq e ".steps[$i].continue_on_error // false" "$yaml_file")
          step_timeout_ms=$(yq e ".steps[$i].timeout_ms // 30000" "$yaml_file")

          # convertir timeout de ms a segundos enteros (timeout(1) usa segundos)
          local timeout_sec
          timeout_sec=$(( (step_timeout_ms + 999) / 1000 ))

          # solo soportamos action=command
          if [[ "$step_action" != "command" ]]; then
              local err_msg="action '$step_action' no soportada (solo command)"
              printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
                  "$step_name" "$step_action" "error" "0" "" "$err_msg" >> "$tsv_file"
              failed_steps=$((failed_steps + 1))

              if [[ "$step_continue" != "true" ]]; then
                  echo "run_steps: paso '$step_name' — $err_msg — abortando" >&2
                  break
              fi
              continue
          fi

          if [[ -z "$step_command" ]]; then
              local err_msg="campo 'command' vacio en paso '$step_name'"
              printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
                  "$step_name" "command" "error" "0" "" "$err_msg" >> "$tsv_file"
              failed_steps=$((failed_steps + 1))

              if [[ "$step_continue" != "true" ]]; then
                  echo "run_steps: $err_msg — abortando" >&2
                  break
              fi
              continue
          fi

          # ejecutar con timeout y capturar output + tiempos
          local t_start t_end elapsed_ms step_output step_exit

          t_start=$(date +%s%3N)
          step_output=$(timeout "$timeout_sec" bash -c "$step_command" 2>&1)
          step_exit=$?
          t_end=$(date +%s%3N)
          elapsed_ms=$(( t_end - t_start ))

          # timeout(1) sale con 124 cuando agota el tiempo
          local step_error=""
          if [[ "$step_exit" -eq 124 ]]; then
              step_error="timeout: comando excedio ${step_timeout_ms}ms"
          fi

          # escapar tabuladores y saltos de linea en el output para el TSV
          local safe_output
          safe_output=$(printf '%s' "$step_output" | tr '\t' ' ' | tr '\n' ' ')

          if [[ "$step_exit" -eq 0 ]]; then
              printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
                  "$step_name" "command" "ok" "$elapsed_ms" "$safe_output" "" >> "$tsv_file"
              ok_steps=$((ok_steps + 1))
          else
              printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
                  "$step_name" "command" "error" "$elapsed_ms" "$safe_output" "$step_error" >> "$tsv_file"
              failed_steps=$((failed_steps + 1))

              if [[ "$step_continue" != "true" ]]; then
                  echo "run_steps: paso '$step_name' fallo (exit $step_exit) — abortando" >&2
                  break
              fi
          fi
      done

      local t_run_end
      t_run_end=$(date +%s%3N)
      local total_duration_ms=$(( t_run_end - t_run_start ))
      local ended_at
      ended_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
      local total_steps=$(( ok_steps + failed_steps ))

      # --- calcular status y exit code ---
      local status_code
      status_code=$(exit_with_status "$total_steps" "$ok_steps" "$failed_steps")

      local run_status
      case "$status_code" in
          0) run_status="success" ;;
          1) run_status="failure" ;;
          2)
              if [[ "$strict" -eq 1 ]]; then
                  run_status="failure"
                  status_code=1
              else
                  run_status="partial"
              fi
              ;;
          *) run_status="failure"; status_code=1 ;;
      esac

      # --- generar JSON a stdout ---
      report_execution_json \
          "$run_name" \
          "$run_status" \
          "$status_code" \
          "$started_at" \
          "$ended_at" \
          "$total_duration_ms" \
          "$tsv_file"

      return "$status_code"
  }

params_schema:
  {"params":[{"name":"yaml_file","desc":"archivo YAML con especificación de pasos"},{"name":"--strict","desc":"flag opcional para mapear partial a failure"}],"output":"JSON de ejecución con detalles de cada paso"}


id: validate_registry_paths_bash_shell
name: validate_registry_paths
kind: function
lang: bash
domain: shell
version: 1.0.0
purity: impure
signature: validate_registry_paths(db_path: string, table: string, root_dir: string) -> tsv_stdout
tags: ["registry","validation","paths","audit","bash"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/shell/validate_registry_paths.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: eb3eacaa9aaf99f8e96758d349a0d22db148b7ff821d05a5a0ad4d44cfac23b4
description:
  Consulta registry.db y verifica que cada file_path apunte a un archivo existente en disco. Imprime a stdout las rutas rotas en formato TSV (id, file_path, domain, tabla).

example:
  ```bash
  source validate_registry_paths.sh
  validate_registry_paths /home/lucas/fn_registry/registry.db functions /home/lucas/fn_registry

  # Output (TSV):
  # cdp_click_go_browser	functions/infra/cdp_click.go	browser	functions
  ```

notes:
  Impura porque lee el filesystem y la base de datos. No modifica nada — solo reporta.

  La salida TSV es consumible por otros scripts o pipelines. Si no hay rutas rotas, no imprime nada.

code:
  #!/usr/bin/env bash
  # validate_registry_paths
  # -----------------------
  # Consulta registry.db y verifica que cada file_path apunte a un archivo existente.
  # Recibe la ruta a registry.db y la tabla a validar (functions o types).
  # Imprime a stdout las lineas con rutas rotas en formato TSV:
  #   id<TAB>file_path<TAB>domain<TAB>tabla
  # Exit code 0 siempre (es una consulta, no una asercion).
  #
  # USO (sourced):
  #   source validate_registry_paths.sh
  #   validate_registry_paths /ruta/registry.db functions /ruta/raiz
  #
  # USO (directo):
  #   bash validate_registry_paths.sh /ruta/registry.db functions /ruta/raiz

  validate_registry_paths() {
      local db_path="$1"
      local table="$2"
      local root_dir="$3"

      if [[ -z "$db_path" || -z "$table" || -z "$root_dir" ]]; then
          echo "validate_registry_paths: uso: validate_registry_paths <db_path> <table> <root_dir>" >&2
          return 1
      fi

      if [[ "$table" != "functions" && "$table" != "types" ]]; then
          echo "validate_registry_paths: tabla debe ser 'functions' o 'types'" >&2
          return 1
      fi

      local broken=0
      while IFS='|' read -r id fp domain; do
          if [[ ! -f "$root_dir/$fp" ]]; then
              printf '%s\t%s\t%s\t%s\n' "$id" "$fp" "$domain" "$table"
              ((broken++))
          fi
      done < <(sqlite3 "$db_path" "SELECT id, file_path, domain FROM $table ORDER BY id;")

      return 0
  }

  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      validate_registry_paths "$@"
  fi

params_schema:
  {"params":[{"name":"db_path","desc":"ruta a registry.db"},{"name":"table","desc":"tabla a validar: functions o types"},{"name":"root_dir","desc":"directorio raíz del repositorio"}],"output":"TSV con id, file_path, domain, tabla de rutas inválidas"}


id: dashboard_grid_cpp_core
name: dashboard_grid
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
tags: ["imgui","grid","layout","dashboard","responsive"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/dashboard_grid.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: af6d4a2555dc0b15f4cadb53fe5f83a09ae139b9ceb86c6de3eb5d36ce3fef71
signature:
  void dashboard_grid_begin(int columns = 2, float spacing = 8.0f); void dashboard_grid_next(); void dashboard_grid_end()

description:
  Grid de N columnas para distribuir widgets de dashboard automaticamente con spacing uniforme entre columnas

notes:
  - Llamar `dashboard_grid_next()` entre cada par de widgets, **no** antes del primero ni despues del ultimo.
  - El numero de `dashboard_grid_next()` puede ser mayor que `columns - 1`: el grid hace wrap automatico a la siguiente fila.
  - Combina bien con `dashboard_panel_begin`/`dashboard_panel_end` para crear dashboards con paneles alineados en cuadricula.
  - Si `columns <= 0` se fuerza a 1 para evitar division por cero.

documentation:
  # dashboard_grid

  Divide el ancho disponible en N columnas con spacing uniforme y posiciona cada widget en su celda automaticamente. Soporta grids anidados mediante un stack interno de estado.

  ## Uso

  ```cpp
  dashboard_grid_begin(3, 8.0f);

  // Celda 0
  dashboard_panel_begin("CPU");
  ImGui::Text("%.1f %%", cpu_pct);
  dashboard_panel_end();
  dashboard_grid_next();

  // Celda 1
  dashboard_panel_begin("Memory");
  ImGui::Text("%.0f MB", mem_mb);
  dashboard_panel_end();
  dashboard_grid_next();

  // Celda 2
  dashboard_panel_begin("Disk");
  ImGui::Text("%.0f GB", disk_gb);
  dashboard_panel_end();

  dashboard_grid_end();
  ```

  ## Implementacion

  ### Calculo de ancho

  ```
  col_width = (available_width - spacing * (columns - 1)) / columns
  ```

  `available_width` se obtiene de `ImGui::GetContentRegionAvail().x` en el momento de `dashboard_grid_begin`.

  ### Mecanica de celdas

  Cada celda es un `BeginGroup`/`EndGroup` con `PushItemWidth(col_width)` para que los widgets internos respeten el ancho de columna. Al llamar `dashboard_grid_next`:

  1. Se cierra la celda actual (`PopItemWidth`, `EndGroup`).
  2. Se incrementa el contador interno.
  3. Si `counter % columns != 0` se emite `SameLine(0, spacing)` para continuar en la misma fila.
  4. Si `counter % columns == 0` no se emite `SameLine`: ImGui pasa a la siguiente fila automaticamente.
  5. Se abre la nueva celda (`BeginGroup`, `PushItemWidth`).

  ### Grids anidados

  El estado (columnas, spacing, col_width, counter) se guarda en `g_grid_stack` (un `std::vector` de structs en un namespace anonimo). Cada llamada a `dashboard_grid_begin` hace `push_back` y `dashboard_grid_end` hace `pop_back`, permitiendo anidar grids sin conflicto.

code:
  #include "dashboard_grid.h"
  #include <imgui.h>
  #include <vector>

  // Internal state stack to support nested grids.
  namespace {

  struct GridState {
      int   columns;
      float spacing;
      float col_width;
      int   counter;  // number of dashboard_grid_next() calls so far
  };

  static std::vector<GridState> g_grid_stack;

  } // namespace

  void dashboard_grid_begin(int columns, float spacing) {
      if (columns < 1) columns = 1;

      float available = ImGui::GetContentRegionAvail().x;
      float col_width = (available - spacing * static_cast<float>(columns - 1))
                        / static_cast<float>(columns);
      if (col_width < 1.0f) col_width = 1.0f;

      g_grid_stack.push_back({columns, spacing, col_width, 0});

      ImGui::BeginGroup();
      ImGui::PushItemWidth(col_width);
  }

  void dashboard_grid_next() {
      if (g_grid_stack.empty()) return;

      GridState& s = g_grid_stack.back();

      ImGui::PopItemWidth();
      ImGui::EndGroup();

      s.counter++;

      if (s.counter % s.columns != 0) {
          // Same row: advance horizontally.
          ImGui::SameLine(0.0f, s.spacing);
      }
      // If counter % columns == 0 the next BeginGroup starts a new row automatically.

      ImGui::BeginGroup();
      ImGui::PushItemWidth(s.col_width);
  }

  void dashboard_grid_end() {
      if (g_grid_stack.empty()) return;

      ImGui::PopItemWidth();
      ImGui::EndGroup();

      g_grid_stack.pop_back();
  }

params_schema:
  {"params":[{"name":"columns","desc":"Numero de columnas del grid (minimo 1); el ancho disponible se divide uniformemente entre ellas"},{"name":"spacing","desc":"Espacio horizontal entre columnas en pixels"}],"output":"Layout de grid aplicado al contenido entre dashboard_grid_begin/end; cada celda recibe un ancho uniforme calculado a partir del espacio disponible"}


id: dashboard_panel_cpp_core
name: dashboard_panel
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
tags: ["imgui","panel","container","layout","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/dashboard_panel.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 2471531214723d7570258d0eb1a0c7a19e9e72c7ddb70b670ecbe3de4094559f
signature:
  bool dashboard_panel_begin(const char* title, float min_width = 200.0f, float min_height = 150.0f); void dashboard_panel_end()

description:
  Contenedor estilizado tipo panel para dashboards con titulo, bordes redondeados y tamaño minimo configurable

notes:
  - El titulo actua como ID de ImGui: dos paneles con el mismo titulo en el mismo frame se comportan como uno solo. Usar `##` para diferenciar IDs si se repite el texto: `"Revenue##panel1"`.
  - `AutoResizeY` hace que el panel crezca verticalmente con su contenido; `min_height` establece el piso.
  - El patron begin/end es idomatico en ImGui: `end` debe llamarse siempre para hacer pop de los estilos, aunque `begin` retorne false.

documentation:
  # dashboard_panel

  Panel estilizado para dashboards ImGui. Envuelve un `BeginChild`/`EndChild` con estilos predefinidos: fondo oscuro (`#1F1F26`), bordes redondeados (5 px), borde visible y separador bajo el titulo.

  ## Uso

  ```cpp
  if (dashboard_panel_begin("Revenue", 300.0f, 200.0f)) {
      line_plot("Revenue", xs, ys, N);
  }
  dashboard_panel_end(); // siempre llamar
  ```

  ## Implementacion

  - `PushStyleVar` aplica `ChildRounding = 5.0f` y `ChildBorderSize = 1.0f`
  - `PushStyleColor` establece el fondo del child a `(0.12, 0.12, 0.15, 1.0)`
  - `BeginChild` con `ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY`
  - Titulo con `TextUnformatted` seguido de `Separator`
  - `dashboard_panel_end` hace `EndChild`, `PopStyleColor(1)`, `PopStyleVar(2)`

code:
  #include "dashboard_panel.h"
  #include <imgui.h>

  bool dashboard_panel_begin(const char* title, float min_width, float min_height) {
      ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 5.0f);
      ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
      ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.12f, 0.12f, 0.15f, 1.0f));

      ImVec2 size(min_width > 0.0f ? min_width : 0.0f,
                  min_height > 0.0f ? min_height : 0.0f);

      ImGui::BeginChild(title, size, ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY);

      ImGui::TextUnformatted(title);
      ImGui::Separator();

      return true;
  }

  void dashboard_panel_end() {
      ImGui::EndChild();
      ImGui::PopStyleColor(1);
      ImGui::PopStyleVar(2);
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del panel, tambien sirve como ID de ImGui para distinguir multiples paneles"},{"name":"min_width","desc":"Ancho minimo del panel en pixels (0 = sin restriccion)"},{"name":"min_height","desc":"Alto minimo del panel en pixels (0 = sin restriccion)"}],"output":"true si el panel es visible y se debe renderizar contenido; llamar siempre dashboard_panel_end() independientemente del valor de retorno"}


id: docking_layout_cpp_core
name: docking_layout
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: ImGuiID docking_layout(DockPreset preset = DockPreset::Default)
description: Configura un docking space con presets de layout predefinidos para dashboards
tags: ["imgui","docking","layout","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/docking_layout.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 2beea4a5dadc1a44cb60268bf7792617e35516eafb64f6cbf807be84ab53a349
example:
  ```cpp
  void render_fn() {
      ImGuiID dock = docking_layout(DockPreset::SidebarLeft);

      ImGui::Begin("Filters");
      // controles del sidebar
      ImGui::End();

      ImGui::Begin("Main");
      // contenido principal
      ImGui::End();
  }
  ```

notes:
  Requiere que `ImGuiConfigFlags_DockingEnable` este activo en `ImGui::GetIO().ConfigFlags` (habilitado por `app_base.cpp`). El preset se aplica solo en el primer frame (static bool). `imgui_internal.h` es necesario para `DockBuilder*`.

documentation:
  # docking_layout

  Configura un docking space fullscreen sobre el viewport principal con presets de layout predefinidos.

  Usa `DockSpaceOverViewport` para crear el dockspace y, en el primer frame, aplica el preset con `DockBuilderSplitNode`. Llamar una vez al inicio de cada frame, antes de renderizar las ventanas hijas.

  ## Presets disponibles

  | Preset | Layout |
  |---|---|
  | Default | Dockspace completo sin divisiones |
  | TwoColumns | Izquierda 60% / Derecha 40% |
  | ThreeColumns | Tres columnas iguales ~33% |
  | SidebarLeft | Sidebar 25% / Main 75% |
  | SidebarRight | Main 75% / Sidebar 25% |
  | TopBottom | Arriba 60% / Abajo 40% |
  | Dashboard | Grid 2x2 de cuatro paneles |

code:
  #include "core/docking_layout.h"
  #include "imgui_internal.h"

  ImGuiID docking_layout(DockPreset preset) {
      const ImGuiViewport* viewport = ImGui::GetMainViewport();
      ImGuiID dockspace_id = ImGui::DockSpaceOverViewport(0, viewport);

      static bool initialized = false;
      if (initialized) {
          return dockspace_id;
      }
      initialized = true;

      if (preset == DockPreset::Default) {
          return dockspace_id;
      }

      ImGui::DockBuilderRemoveNode(dockspace_id);
      ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace);
      ImGui::DockBuilderSetNodeSize(dockspace_id, viewport->Size);

      if (preset == DockPreset::TwoColumns) {
          ImGuiID right;
          ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.6f, nullptr, &right);

      } else if (preset == DockPreset::ThreeColumns) {
          ImGuiID center, right;
          ImGuiID left_node;
          ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.33f, &left_node, &center);
          ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.5f, nullptr, &right);

      } else if (preset == DockPreset::SidebarLeft) {
          ImGuiID main;
          ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.25f, nullptr, &main);

      } else if (preset == DockPreset::SidebarRight) {
          ImGuiID sidebar;
          ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Right, 0.25f, &sidebar, nullptr);

      } else if (preset == DockPreset::TopBottom) {
          ImGuiID bottom;
          ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Up, 0.6f, nullptr, &bottom);

      } else if (preset == DockPreset::Dashboard) {
          ImGuiID top, bottom;
          ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Up, 0.5f, &top, &bottom);
          ImGui::DockBuilderSplitNode(top,    ImGuiDir_Left, 0.5f, nullptr, nullptr);
          ImGui::DockBuilderSplitNode(bottom, ImGuiDir_Left, 0.5f, nullptr, nullptr);
      }

      ImGui::DockBuilderFinish(dockspace_id);
      return dockspace_id;
  }

params_schema:
  {"params":[{"name":"preset","desc":"Layout predefinido a aplicar (Default, TwoColumns, ThreeColumns, SidebarLeft, SidebarRight, TopBottom, Dashboard)"}],"output":"ID del dockspace creado, usable con ImGui::SetNextWindowDockID"}


id: fps_overlay_cpp_core
name: fps_overlay
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: void fps_overlay()
tags: ["imgui","fps","overlay","profiling","debug"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/fps_overlay.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 1a1d25ddac5db7eef21dee59a15e38cd6ace1059e873d19556df7319a5b801d8
params_schema: {"output":"Renderiza el overlay de FPS en el frame ImGui actual"}
description:
  Renderiza un overlay de FPS y frametime en la esquina superior derecha, con soporte opcional de Tracy

documentation:
  # fps_overlay

  Muestra FPS y frametime (ms) en una ventana semi-transparente en la esquina superior derecha.

  Si se compila con `TRACY_ENABLE`, incluye un `ZoneScoped` para profiling con Tracy.

code:
  #include "core/fps_overlay.h"
  #include "imgui.h"

  #ifdef TRACY_ENABLE
  #include "tracy/Tracy.hpp"
  #endif

  void fps_overlay() {
  #ifdef TRACY_ENABLE
      ZoneScoped;
  #endif

      ImGuiIO& io = ImGui::GetIO();
      ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration
                             | ImGuiWindowFlags_AlwaysAutoResize
                             | ImGuiWindowFlags_NoSavedSettings
                             | ImGuiWindowFlags_NoFocusOnAppearing
                             | ImGuiWindowFlags_NoNav
                             | ImGuiWindowFlags_NoMove;

      const float pad = 10.0f;
      const ImGuiViewport* viewport = ImGui::GetMainViewport();
      ImVec2 pos(viewport->WorkPos.x + viewport->WorkSize.x - pad,
                 viewport->WorkPos.y + pad);
      ImGui::SetNextWindowPos(pos, ImGuiCond_Always, ImVec2(1.0f, 0.0f));
      ImGui::SetNextWindowBgAlpha(0.65f);

      if (ImGui::Begin("##fps_overlay", nullptr, flags)) {
          ImGui::Text("%.1f FPS", io.Framerate);
          ImGui::Text("%.3f ms", 1000.0f / io.Framerate);
      }
      ImGui::End();
  }


id: fullscreen_window_cpp_core
name: fullscreen_window
kind: component
lang: cpp
domain: core
version: 0.1.0
purity: pure
signature: bool fullscreen_window_begin(const char* id = "##fullscreen"); void fullscreen_window_end()
tags: ["imgui","layout","fullscreen","window"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/fullscreen_window.cpp
created_at: 2026-04-07T22:24:27Z
updated_at: 2026-04-07T22:24:27Z
props: []
emits: []
framework: imgui
variant: []
content_hash: cf3510f5959117e66eb340deae1d7b929588aa536f25ab649c655615a3c5a289
description:
  Ventana ImGui fullscreen sin decoraciones que ocupa todo el viewport, elimina la necesidad de usar el sistema de ventanas interno

notes:
  - Pura en el sentido de que no hace I/O ni tiene estado propio; solo configura el estado next-frame de ImGui.
  - `WorkPos`/`WorkSize` respetan los menu bars del sistema operativo (en plataformas que los tienen). Para ocupar literalmente toda la pantalla usar `Pos`/`Size` del viewport.
  - Compatible con `dashboard_grid` y `dashboard_panel`: el fullscreen_window actua como contenedor raiz y los paneles/grids se renderizan dentro.
  - El patron begin/end es idiomatico en ImGui: `end` debe llamarse siempre para cerrar la ventana correctamente, aunque `begin` retorne false.

documentation:
  # fullscreen_window

  Wrapper que crea una ventana ImGui que ocupa exactamente el viewport de trabajo (`WorkPos` / `WorkSize`). Elimina todas las decoraciones: title bar, resize grip, move, collapse. Ideal como capa raiz de una aplicacion ImGui donde el contenido propio gestiona el layout.

  ## Uso

  ```cpp
  if (fullscreen_window_begin()) {
      // todo el layout de la app va aqui
      dashboard_grid_begin(3, 8.0f);
      // ...
      dashboard_grid_end();
  }
  fullscreen_window_end(); // siempre llamar
  ```

  Con ID explicito (si se necesitan multiples capas):

  ```cpp
  if (fullscreen_window_begin("##background")) {
      render_background();
  }
  fullscreen_window_end();
  ```

  ## Implementacion

  - `GetMainViewport()` obtiene el viewport principal (compatible con viewports multi-monitor de ImGui)
  - `SetNextWindowPos(vp->WorkPos)` posiciona en el area de trabajo (excluye menu bars del OS)
  - `SetNextWindowSize(vp->WorkSize)` ocupa exactamente el area disponible
  - Flags: `NoTitleBar | NoResize | NoMove | NoCollapse | NoBringToFrontOnFocus | NoNavFocus`
  - `NoBringToFrontOnFocus` y `NoNavFocus` evitan que la ventana fullscreen robe el foco de ventanas superpuestas

code:
  #include "core/fullscreen_window.h"
  #include "imgui.h"

  bool fullscreen_window_begin(const char* id) {
      const ImGuiViewport* vp = ImGui::GetMainViewport();
      ImGui::SetNextWindowPos(vp->WorkPos);
      ImGui::SetNextWindowSize(vp->WorkSize);
      return ImGui::Begin(id, nullptr,
          ImGuiWindowFlags_NoTitleBar      |
          ImGuiWindowFlags_NoResize        |
          ImGuiWindowFlags_NoMove          |
          ImGuiWindowFlags_NoCollapse      |
          ImGuiWindowFlags_NoBringToFrontOnFocus |
          ImGuiWindowFlags_NoNavFocus);
  }

  void fullscreen_window_end() {
      ImGui::End();
  }

params_schema:
  {"params":[{"name":"id","desc":"Identificador ImGui de la ventana, default ##fullscreen (el prefijo ## oculta el texto del titulo)"}],"output":"true si la ventana es visible (siempre true en fullscreen); llamar siempre fullscreen_window_end() independientemente del valor de retorno"}


id: graph_spatial_hash_cpp_core
name: graph_spatial_hash
kind: function
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: SpatialHash(float cell_size, int table_size)
tags: ["spatial","hash","acceleration","graph","query","hittest"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: cpp/functions/core/graph_spatial_hash.cpp
created_at: 2026-04-07T22:04:57Z
updated_at: 2026-04-07T22:04:57Z
props: []
emits: []
framework: imgui
variant: []
content_hash: a5192801758a6cfb6edc9d9ed505000f8013c39539a6bf4bdff5a879177e81b4
description:
  Spatial hash grid para busqueda O(1) de puntos por posicion, usado para hit-testing de nodos en grafos

notes:
  - No es thread-safe: disenado para uso single-threaded en el hilo de render de ImGui.
  - `build()` debe llamarse cada frame si las posiciones de los nodos cambian.
  - `cell_size` debe ser >= al radio maximo de los nodos para que la vecindad 3x3 capture todos los candidatos posibles.
  - La distancia efectiva al nodo se calcula como `dist(centro) - size`, de modo que el hit-test funciona correctamente para nodos de distintos tamaños.
  - No implementa `operator=` ni copy constructor (deleted) para evitar doble-free de los arrays internos.

documentation:
  # graph_spatial_hash

  Spatial hash grid para busqueda eficiente de nodos en grafos por posicion 2D. Util para hit-testing de nodos en editores de grafos basados en ImGui, donde se necesita saber que nodo esta bajo el cursor en cada frame.

  ## Uso

  ```cpp
  SpatialHash sh(20.0f, 4096);

  // Reconstruir cada frame (o cuando cambian posiciones)
  sh.build(node_xs, node_ys, node_sizes, node_count);

  // Hit-test: nodo mas cercano al cursor
  float dist;
  int hovered = sh.query_nearest(mouse_x, mouse_y, 30.0f, &dist);

  // Seleccion por area: todos los nodos dentro de un radio
  int results[256];
  int count = sh.query_radius(center_x, center_y, radius, results, 256);
  ```

  ## Estructura interna

  - `buckets[table_size]`: cada entrada contiene el indice del primer slot de la cadena, o -1 si el bucket esta vacio.
  - `entries[2 * entry_capacity]`: pares `[node_index, next_in_chain]` almacenados como flat array. Se expande con `realloc` si el numero de nodos supera la capacidad inicial (256).
  - El snapshot de posiciones (`xs`, `ys`, `sizes`) se copia internamente en `build()` para que `query_nearest` y `query_radius` puedan calcular distancias sin recibir los arrays como parametro.

  ## Hash function

  ```
  hash(cx, cy) = (cx * 73856093 ^ cy * 19349663) % table_size
  ```

  Donde `cx = floor(x / cell_size)`, `cy = floor(y / cell_size)`.

code:
  #include "graph_spatial_hash.h"

  #include <cmath>
  #include <cstdlib>
  #include <cstring>

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  static inline int floor_div(float v, float cell) {
      return static_cast<int>(std::floor(v / cell));
  }

  static inline float sq(float x) { return x * x; }

  // ---------------------------------------------------------------------------
  // Snapshot de posiciones (almacenado internamente en build)
  // La interfaz del header no pasa xs/ys a los metodos de query, por lo que
  // SpatialHash conserva copias internas para calcular distancias.
  // ---------------------------------------------------------------------------

  struct SpatialHashSnap {
      float* xs    = nullptr;
      float* ys    = nullptr;
      float* sizes = nullptr;
      int    count = 0;
  };

  // Almacenamos el snapshot en memoria contigua al final del bloque de entries,
  // para no añadir campos al header publico. Usamos un puntero opaco en un
  // campo reservado. Como el header ya esta fijado, guardamos el snapshot como
  // una variable estatica por instancia — aceptable para uso single-threaded
  // tipico de ImGui. Para multi-instancia se puede usar un map.
  //
  // En la practica, la convencion del registry es que SpatialHash se crea una
  // vez por frame de ImGui y se usa en el mismo hilo de render.

  static SpatialHashSnap g_snap;   // snapshot mas reciente

  // ---------------------------------------------------------------------------
  // SpatialHash
  // ---------------------------------------------------------------------------

  int SpatialHash::cell_hash(int cx, int cy) const {
      unsigned int h = static_cast<unsigned int>(cx) * 73856093u
                     ^ static_cast<unsigned int>(cy) * 19349663u;
      return static_cast<int>(h % static_cast<unsigned int>(table_size));
  }

  SpatialHash::SpatialHash(float cell_size_, int table_size_)
      : cell_size(cell_size_)
      , table_size(table_size_)
      , entry_count(0)
      , entry_capacity(256)
  {
      buckets = static_cast<int*>(std::malloc(
          static_cast<std::size_t>(table_size) * sizeof(int)));

      entries = static_cast<int*>(std::malloc(
          static_cast<std::size_t>(entry_capacity) * 2 * sizeof(int)));

      std::memset(buckets, -1,
          static_cast<std::size_t>(table_size) * sizeof(int));
  }

  SpatialHash::~SpatialHash() {
      std::free(buckets);
      std::free(entries);

      std::free(g_snap.xs);
      std::free(g_snap.ys);
      std::free(g_snap.sizes);
      g_snap = {};
  }

  void SpatialHash::build(const float* xs, const float* ys, const float* sizes, int count) {
      // --- Limpiar tabla ---
      std::memset(buckets, -1,
          static_cast<std::size_t>(table_size) * sizeof(int));
      entry_count = 0;

      // --- Snapshot de posiciones para queries ---
      if (g_snap.count < count) {
          std::free(g_snap.xs);
          std::free(g_snap.ys);
          std::free(g_snap.sizes);
          g_snap.xs    = static_cast<float*>(std::malloc(static_cast<std::size_t>(count) * sizeof(float)));
          g_snap.ys    = static_cast<float*>(std::malloc(static_cast<std::size_t>(count) * sizeof(float)));
          g_snap.sizes = static_cast<float*>(std::malloc(static_cast<std::size_t>(count) * sizeof(float)));
      }
      std::memcpy(g_snap.xs,    xs,    static_cast<std::size_t>(count) * sizeof(float));
      std::memcpy(g_snap.ys,    ys,    static_cast<std::size_t>(count) * sizeof(float));
      std::memcpy(g_snap.sizes, sizes, static_cast<std::size_t>(count) * sizeof(float));
      g_snap.count = count;

      // --- Insertar nodos en la tabla hash ---
      for (int i = 0; i < count; ++i) {
          int cx     = floor_div(xs[i], cell_size);
          int cy     = floor_div(ys[i], cell_size);
          int bucket = cell_hash(cx, cy);

          // Crecer entries si necesario
          if (entry_count >= entry_capacity) {
              entry_capacity *= 2;
              entries = static_cast<int*>(std::realloc(
                  entries,
                  static_cast<std::size_t>(entry_capacity) * 2 * sizeof(int)));
          }

          // Insertar al frente de la cadena encadenada
          int slot = entry_count++;
          entries[slot * 2 + 0] = i;               // node_index
          entries[slot * 2 + 1] = buckets[bucket];  // next_in_chain
          buckets[bucket] = slot;
      }
  }

  int SpatialHash::query_nearest(float qx, float qy, float radius, float* out_dist) const {
      int   best_idx = -1;
      float best_d   = radius;  // umbral: solo aceptamos dentro del radio

      int qcx = floor_div(qx, cell_size);
      int qcy = floor_div(qy, cell_size);

      // Escanear vecindad 3x3 de celdas
      for (int dy = -1; dy <= 1; ++dy) {
          for (int dx = -1; dx <= 1; ++dx) {
              int bucket = cell_hash(qcx + dx, qcy + dy);
              int slot   = buckets[bucket];
              while (slot != -1) {
                  int ni      = entries[slot * 2 + 0];
                  float ex    = g_snap.xs[ni] - qx;
                  float ey    = g_snap.ys[ni] - qy;
                  float dist  = std::sqrt(sq(ex) + sq(ey)) - g_snap.sizes[ni];
                  if (dist < best_d) {
                      best_d   = dist;
                      best_idx = ni;
                  }
                  slot = entries[slot * 2 + 1];
              }
          }
      }

      if (out_dist && best_idx != -1)
          *out_dist = best_d;

      return best_idx;
  }

  int SpatialHash::query_radius(float qx, float qy, float radius, int* out, int max_out) const {
      int found = 0;

      int qcx = floor_div(qx, cell_size);
      int qcy = floor_div(qy, cell_size);

      for (int dy = -1; dy <= 1; ++dy) {
          for (int dx = -1; dx <= 1; ++dx) {
              int bucket = cell_hash(qcx + dx, qcy + dy);
              int slot   = buckets[bucket];
              while (slot != -1 && found < max_out) {
                  int ni     = entries[slot * 2 + 0];
                  float ex   = g_snap.xs[ni] - qx;
                  float ey   = g_snap.ys[ni] - qy;
                  float dist = std::sqrt(sq(ex) + sq(ey)) - g_snap.sizes[ni];
                  if (dist <= radius)
                      out[found++] = ni;
                  slot = entries[slot * 2 + 1];
              }
          }
      }

      return found;
  }

params_schema:
  {"params":[{"name":"cell_size","desc":"Tamaño de cada celda del grid espacial (debe ser \u003e= radio maximo de nodos)"},{"name":"table_size","desc":"Numero de buckets en la tabla hash (potencia de 2 recomendada para distribucion uniforme)"}],"output":"Estructura de hash espacial lista para queries de puntos cercanos"}


id: memory_overlay_cpp_core
name: memory_overlay
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: void memory_overlay()
description: Renderiza un overlay de estadisticas de memoria (RSS, peak, vsize) en la esquina inferior derecha
tags: ["imgui","memory","overlay","debug","dashboard","profiling"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
example:
  ```cpp
  // En el loop de render, dentro de un frame ImGui:
  fps_overlay();
  memory_overlay();
  ```

tests: []
file_path: cpp/functions/core/memory_overlay.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 7c325bf54f798be7495f41e26cb8c6110baa191bcf11fc37e8f2df409e90b7d7
params_schema: {"output":"Renderiza el overlay de memoria en el frame ImGui actual"}
notes:
  - La función sigue la misma convención que `fps_overlay`: mismos flags de ventana, mismo alpha (0.65).
  - Posición: esquina inferior derecha (`pivot = {1,1}`), separada 10 px del borde.
  - La lectura de `/proc/self/status` es I/O local de ~0.1 ms, amortizada a 1 Hz — despreciable.
  - Con `TRACY_ENABLE` activo, la función registra una zona `ZoneScoped` para que aparezca en el profiler.
  - Si la plataforma no puede leer las estadísticas, muestra "Memory: N/A" en gris.

documentation:
  # memory_overlay

  Muestra estadísticas de memoria del proceso en una ventana semi-transparente en la esquina inferior derecha. Complementa `fps_overlay` para un dashboard mínimo de rendimiento.

  Las métricas se leen como máximo una vez por segundo y se cachean en una variable estática local, evitando I/O excesivo en el hot path de render.

  ## Métricas mostradas

  | Campo | Linux | Windows |
  |---|---|---|
  | RSS (MB) | `VmRSS` de `/proc/self/status` | `WorkingSetSize` (PSAPI) |
  | Peak RSS (MB) | `VmPeak` de `/proc/self/status` | `PeakWorkingSetSize` (PSAPI) |
  | Virtual Size (MB) | `VmSize` de `/proc/self/status` | N/A (no mostrado) |

  En Windows se requiere enlazar `psapi.lib` (el `.cpp` incluye `#pragma comment(lib, "psapi.lib")`).

code:
  #include "core/memory_overlay.h"
  #include "imgui.h"

  #ifdef TRACY_ENABLE
  #include "tracy/Tracy.hpp"
  #endif

  #ifdef _WIN32
  #define WIN32_LEAN_AND_MEAN
  #include <windows.h>
  #include <psapi.h>
  #pragma comment(lib, "psapi.lib")
  #else
  #include <cstdio>
  #endif

  #include <cstring>

  namespace {

  struct MemStats {
      long rss_kb    = -1; // Resident Set Size
      long peak_kb   = -1; // Peak RSS
      long vsize_kb  = -1; // Virtual memory size
  };

  static MemStats s_cached;
  static double   s_last_sample = -1.0;

  #ifdef _WIN32
  static MemStats sample_memory() {
      MemStats s;
      PROCESS_MEMORY_COUNTERS pmc{};
      if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) {
          s.rss_kb  = static_cast<long>(pmc.WorkingSetSize       / 1024);
          s.peak_kb = static_cast<long>(pmc.PeakWorkingSetSize   / 1024);
          s.vsize_kb = -1; // not easily available on Windows without VirtualQuery loop
      }
      return s;
  }
  #else
  static MemStats sample_memory() {
      MemStats s;
      FILE* f = std::fopen("/proc/self/status", "r");
      if (!f) return s;

      char line[128];
      while (std::fgets(line, sizeof(line), f)) {
          long val = 0;
          if (std::sscanf(line, "VmRSS: %ld kB",  &val) == 1) s.rss_kb   = val;
          if (std::sscanf(line, "VmPeak: %ld kB", &val) == 1) s.peak_kb  = val;
          if (std::sscanf(line, "VmSize: %ld kB", &val) == 1) s.vsize_kb = val;
      }
      std::fclose(f);
      return s;
  }
  #endif

  } // namespace

  void memory_overlay() {
  #ifdef TRACY_ENABLE
      ZoneScoped;
  #endif

      // Sample at most once per second
      const double now = ImGui::GetTime();
      if (s_last_sample < 0.0 || (now - s_last_sample) >= 1.0) {
          s_cached      = sample_memory();
          s_last_sample = now;
      }

      ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration
                             | ImGuiWindowFlags_AlwaysAutoResize
                             | ImGuiWindowFlags_NoSavedSettings
                             | ImGuiWindowFlags_NoFocusOnAppearing
                             | ImGuiWindowFlags_NoNav
                             | ImGuiWindowFlags_NoMove;

      const float pad = 10.0f;
      const ImGuiViewport* vp = ImGui::GetMainViewport();
      ImVec2 pos(vp->WorkPos.x + vp->WorkSize.x - pad,
                 vp->WorkPos.y + vp->WorkSize.y - pad);
      ImGui::SetNextWindowPos(pos, ImGuiCond_Always, ImVec2(1.0f, 1.0f));
      ImGui::SetNextWindowBgAlpha(0.65f);

      if (ImGui::Begin("##memory_overlay", nullptr, flags)) {
          if (s_cached.rss_kb >= 0) {
              ImGui::Text("RSS:    %5ld MB", s_cached.rss_kb   / 1024);
              ImGui::Text("Peak:   %5ld MB", s_cached.peak_kb  / 1024);
  #ifndef _WIN32
              ImGui::Text("VSize:  %5ld MB", s_cached.vsize_kb / 1024);
  #endif
          } else {
              ImGui::TextDisabled("Memory: N/A");
          }
      }
      ImGui::End();
  }


id: plot_theme_cpp_core
name: plot_theme
kind: function
lang: cpp
domain: core
version: 1.0.0
purity: pure
tags: ["theme","colors","palette","styling","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui","implot"]
tests: []
file_path: cpp/functions/core/plot_theme.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: ccec236a278c13ca850773915133808c920c0a482636fd354e86e2161376e65a
signature:
  void plot_theme_dark() / void plot_theme_light() / void plot_theme_high_contrast() / void plot_theme_apply(const PlotTheme& theme)

description:
  Gestiona temas y paletas de colores para ImPlot e ImGui, con presets dark/light/high-contrast y soporte para temas custom

notes:
  - `plot_theme_apply` llama a `ImPlot::AddColormap` y `ImPlot::SetColormap` con el campo `name` del tema como identificador. Si se llama dos veces con el mismo `name`, ImPlot registra el colormap de nuevo — usar nombres distintos si se crean multiples variantes en runtime.
  - La funcion es "pure" en el sentido de que su unico efecto es escribir en `ImGuiStyle` e `ImPlotStyle` del contexto activo, sin I/O ni estado global propio.
  - Requiere que `ImGui::CreateContext()` e `ImPlot::CreateContext()` hayan sido llamados antes del primer uso.
  - Compatible con C++17. No usa excepciones ni RTTI.

documentation:
  # plot_theme

  Configura el aspecto visual de ventanas ImGui y graficas ImPlot en un solo paso. Tres presets listos para usar y una API de tema custom para casos especificos.

  ## Presets

  ### `plot_theme_dark()`

  Fondo oscuro (`#1A1A1F`) con paleta de 10 colores suaves: steel blue, soft green, warm orange, muted red, lavender, teal, gold, lime, pink, periwinkle. Ideal para dashboards de monitoreo.

  ### `plot_theme_light()`

  Fondo claro (`#F7F7F7`) con colores vibrantes de alto contraste sobre blanco. Recomendado para capturas de pantalla o exportacion a documentos.

  ### `plot_theme_high_contrast()`

  Fondo negro puro con colores neon: cyan, green, orange, magenta, yellow. Disenado para presentaciones en pantallas grandes o condiciones de poca luz.

  ## Uso

  ```cpp
  #include "plot_theme.h"

  // Al inicio del frame, antes de BeginMainMenuBar / Begin
  plot_theme_dark();

  // O con tema custom
  PlotTheme my_theme = plot_theme_preset_dark();
  my_theme.palette[0] = ImVec4(1.0f, 0.0f, 0.5f, 1.0f); // override primer color
  plot_theme_apply(my_theme);
  ```

  ## API

  ```cpp
  // Presets de aplicacion directa
  void plot_theme_dark();
  void plot_theme_light();
  void plot_theme_high_contrast();

  // Tema custom
  void plot_theme_apply(const PlotTheme& theme);

  // Obtener structs para inspeccion o modificacion parcial
  PlotTheme plot_theme_preset_dark();
  PlotTheme plot_theme_preset_light();
  PlotTheme plot_theme_preset_high_contrast();
  ```

  ## Estructura PlotTheme

  ```cpp
  struct PlotTheme {
      const char* name;       // nombre del colormap registrado en ImPlot
      ImVec4 bg;              // fondo del plot (ImPlotCol_PlotBg, ImGuiCol_WindowBg)
      ImVec4 frame_bg;        // fondo del frame (ImPlotCol_FrameBg, ImGuiCol_FrameBg)
      ImVec4 text;            // color del texto (todos los ImGui/ImPlot text cols)
      ImVec4 grid;            // lineas de cuadricula y ticks
      ImVec4 palette[10];     // paleta de colores para series de datos
      int palette_count;      // numero de colores activos en la paleta (1-10)
  };
  ```

code:
  #include "plot_theme.h"

  // ---------------------------------------------------------------------------
  // Preset structs
  // ---------------------------------------------------------------------------

  PlotTheme plot_theme_preset_dark() {
      PlotTheme t;
      t.name          = "dark";
      t.bg            = ImVec4(0.10f, 0.10f, 0.12f, 1.00f);
      t.frame_bg      = ImVec4(0.14f, 0.14f, 0.17f, 1.00f);
      t.text          = ImVec4(0.88f, 0.88f, 0.90f, 1.00f);
      t.grid          = ImVec4(0.30f, 0.30f, 0.35f, 0.60f);
      t.palette_count = 10;
      t.palette[0]    = ImVec4(0.33f, 0.62f, 0.91f, 1.00f); // steel blue
      t.palette[1]    = ImVec4(0.35f, 0.78f, 0.54f, 1.00f); // soft green
      t.palette[2]    = ImVec4(0.96f, 0.60f, 0.25f, 1.00f); // warm orange
      t.palette[3]    = ImVec4(0.85f, 0.38f, 0.42f, 1.00f); // muted red
      t.palette[4]    = ImVec4(0.72f, 0.52f, 0.88f, 1.00f); // lavender
      t.palette[5]    = ImVec4(0.38f, 0.80f, 0.82f, 1.00f); // teal
      t.palette[6]    = ImVec4(0.95f, 0.80f, 0.32f, 1.00f); // gold
      t.palette[7]    = ImVec4(0.60f, 0.82f, 0.38f, 1.00f); // lime
      t.palette[8]    = ImVec4(0.90f, 0.55f, 0.75f, 1.00f); // pink
      t.palette[9]    = ImVec4(0.55f, 0.70f, 0.95f, 1.00f); // periwinkle
      return t;
  }

  PlotTheme plot_theme_preset_light() {
      PlotTheme t;
      t.name          = "light";
      t.bg            = ImVec4(0.97f, 0.97f, 0.97f, 1.00f);
      t.frame_bg      = ImVec4(1.00f, 1.00f, 1.00f, 1.00f);
      t.text          = ImVec4(0.10f, 0.10f, 0.12f, 1.00f);
      t.grid          = ImVec4(0.70f, 0.70f, 0.72f, 0.60f);
      t.palette_count = 10;
      t.palette[0]    = ImVec4(0.12f, 0.47f, 0.71f, 1.00f); // deep blue
      t.palette[1]    = ImVec4(0.17f, 0.63f, 0.17f, 1.00f); // deep green
      t.palette[2]    = ImVec4(0.84f, 0.37f, 0.00f, 1.00f); // burnt orange
      t.palette[3]    = ImVec4(0.84f, 0.15f, 0.16f, 1.00f); // vivid red
      t.palette[4]    = ImVec4(0.58f, 0.40f, 0.74f, 1.00f); // purple
      t.palette[5]    = ImVec4(0.09f, 0.75f, 0.81f, 1.00f); // cyan
      t.palette[6]    = ImVec4(0.74f, 0.74f, 0.13f, 1.00f); // olive
      t.palette[7]    = ImVec4(0.09f, 0.75f, 0.81f, 1.00f); // sky blue
      t.palette[8]    = ImVec4(0.89f, 0.47f, 0.76f, 1.00f); // rose
      t.palette[9]    = ImVec4(0.50f, 0.50f, 0.50f, 1.00f); // grey
      return t;
  }

  PlotTheme plot_theme_preset_high_contrast() {
      PlotTheme t;
      t.name          = "high_contrast";
      t.bg            = ImVec4(0.00f, 0.00f, 0.00f, 1.00f);
      t.frame_bg      = ImVec4(0.05f, 0.05f, 0.05f, 1.00f);
      t.text          = ImVec4(1.00f, 1.00f, 1.00f, 1.00f);
      t.grid          = ImVec4(0.30f, 0.30f, 0.30f, 0.80f);
      t.palette_count = 10;
      t.palette[0]    = ImVec4(0.00f, 1.00f, 1.00f, 1.00f); // cyan neon
      t.palette[1]    = ImVec4(0.00f, 1.00f, 0.00f, 1.00f); // green neon
      t.palette[2]    = ImVec4(1.00f, 0.60f, 0.00f, 1.00f); // orange neon
      t.palette[3]    = ImVec4(1.00f, 0.00f, 0.50f, 1.00f); // magenta
      t.palette[4]    = ImVec4(1.00f, 1.00f, 0.00f, 1.00f); // yellow
      t.palette[5]    = ImVec4(0.40f, 0.80f, 1.00f, 1.00f); // sky blue
      t.palette[6]    = ImVec4(1.00f, 0.40f, 0.40f, 1.00f); // salmon
      t.palette[7]    = ImVec4(0.60f, 1.00f, 0.40f, 1.00f); // lime
      t.palette[8]    = ImVec4(1.00f, 0.80f, 1.00f, 1.00f); // lavender bright
      t.palette[9]    = ImVec4(0.80f, 1.00f, 0.80f, 1.00f); // mint bright
      return t;
  }

  // ---------------------------------------------------------------------------
  // Internal helper
  // ---------------------------------------------------------------------------

  static void apply_imgui_colors(const PlotTheme& theme) {
      ImGuiStyle& s = ImGui::GetStyle();
      s.Colors[ImGuiCol_WindowBg]       = theme.bg;
      s.Colors[ImGuiCol_ChildBg]        = theme.bg;
      s.Colors[ImGuiCol_PopupBg]        = theme.frame_bg;
      s.Colors[ImGuiCol_FrameBg]        = theme.frame_bg;
      s.Colors[ImGuiCol_FrameBgHovered] = theme.frame_bg;
      s.Colors[ImGuiCol_FrameBgActive]  = theme.frame_bg;
      s.Colors[ImGuiCol_Text]           = theme.text;
      s.Colors[ImGuiCol_TextDisabled]   = ImVec4(theme.text.x * 0.5f,
                                                 theme.text.y * 0.5f,
                                                 theme.text.z * 0.5f,
                                                 0.80f);
  }

  static void apply_implot_colors(const PlotTheme& theme) {
      ImPlotStyle& ps = ImPlot::GetStyle();
      ps.Colors[ImPlotCol_PlotBg]       = theme.bg;
      ps.Colors[ImPlotCol_PlotBorder]   = theme.frame_bg;
      ps.Colors[ImPlotCol_FrameBg]      = theme.frame_bg;
      ps.Colors[ImPlotCol_LegendBg]     = theme.frame_bg;
      ps.Colors[ImPlotCol_LegendBorder] = theme.grid;
      ps.Colors[ImPlotCol_LegendText]   = theme.text;
      ps.Colors[ImPlotCol_TitleText]    = theme.text;
      ps.Colors[ImPlotCol_InlayText]    = theme.text;
      ps.Colors[ImPlotCol_AxisText]     = theme.text;
      ps.Colors[ImPlotCol_AxisGrid]     = theme.grid;
      ps.Colors[ImPlotCol_AxisTick]     = theme.grid;

      // Register custom colormap from palette
      const int count = theme.palette_count > 10 ? 10 : theme.palette_count;
      ImPlot::AddColormap(theme.name, theme.palette, count, false);
      ImPlot::SetColormap(theme.name);
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  void plot_theme_apply(const PlotTheme& theme) {
      apply_imgui_colors(theme);
      apply_implot_colors(theme);
  }

  void plot_theme_dark() {
      PlotTheme t = plot_theme_preset_dark();
      plot_theme_apply(t);
  }

  void plot_theme_light() {
      PlotTheme t = plot_theme_preset_light();
      plot_theme_apply(t);
  }

  void plot_theme_high_contrast() {
      PlotTheme t = plot_theme_preset_high_contrast();
      plot_theme_apply(t);
  }

params_schema:
  {"params":[{"name":"theme","desc":"Estructura PlotTheme con nombre, colores de fondo/frame/texto/grid y paleta de hasta 10 colores para series de datos"}],"output":"Aplica el tema al contexto ImPlot/ImGui actual modificando ImGuiStyle y ImPlotStyle en memoria"}


id: sidebar_cpp_core
name: sidebar
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: bool sidebar_begin(const char* title, bool* open, float width = 250.0f)
description: Panel lateral colapsable para filtros y controles de dashboard
tags: ["imgui","sidebar","panel","layout","dashboard","controls"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/sidebar.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 6d17e2d385a23082779e6593baa3f6806992f0657326d379bb873260164bdff5
example:
  ```cpp
  static bool filters_open = true;

  void render_fn() {
      if (sidebar_begin("Filters", &filters_open)) {
          ImGui::SliderFloat("Min value", &min_val, 0.0f, 100.0f);
          ImGui::Checkbox("Show inactive", &show_inactive);
      }
      sidebar_end();

      // contenido principal
      ImGui::Begin("Main");
      // ...
      ImGui::End();
  }
  ```

notes:
  El estado de `s_sidebar_was_open` es una variable estatica interna que coordina `sidebar_begin` y `sidebar_end`. Solo un sidebar activo a la vez por frame (patron begin/end clasico de ImGui). Para multiples sidebars simultaneos, instanciar logica propia con estado separado.

documentation:
  # sidebar

  Panel lateral colapsable. Cuando `*open == true` renderiza una ventana ImGui de ancho fijo con boton de cierre. Cuando `*open == false` muestra un boton compacto ">" para reabrir.

  Siempre llamar `sidebar_end()` despues de `sidebar_begin()`, independientemente del valor de retorno.

code:
  #include "core/sidebar.h"
  #include "imgui.h"

  static bool s_sidebar_was_open = false;

  bool sidebar_begin(const char* title, bool* open, float width) {
      if (!*open) {
          s_sidebar_was_open = false;
          if (ImGui::Button(">")) {
              *open = true;
          }
          return false;
      }

      s_sidebar_was_open = true;
      ImGui::SetNextWindowSize(ImVec2(width, 0.0f), ImGuiCond_Always);
      ImGui::Begin(title, open, ImGuiWindowFlags_NoCollapse);
      return true;
  }

  void sidebar_end() {
      if (s_sidebar_was_open) {
          ImGui::End();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del sidebar mostrado en la barra de titulo de la ventana"},{"name":"open","desc":"Puntero al estado abierto/cerrado; se pone a false si el usuario cierra la ventana"},{"name":"width","desc":"Ancho del sidebar en pixels (por defecto 250)"}],"output":"true si el sidebar esta abierto y se debe renderizar contenido entre sidebar_begin y sidebar_end"}


id: tab_container_cpp_core
name: tab_container
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: bool tab_container_begin(const char* id)
description: Contenedor de tabs para organizar vistas multiples en un dashboard
tags: ["imgui","tabs","container","layout","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/tab_container.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 4ab6bab6a6677ab5d3079bf8537e2fd9ae4ebeefe9b68520b57f5ad269c24003
example:
  ```cpp
  void render_fn() {
      ImGui::Begin("Dashboard");

      if (tab_container_begin("##main_tabs")) {
          if (tab_container_tab("Overview")) {
              render_overview();
              tab_container_tab_end();
          }
          if (tab_container_tab("Details")) {
              render_details();
              tab_container_tab_end();
          }
          if (tab_container_tab("Settings")) {
              render_settings();
              tab_container_tab_end();
          }
      }
      tab_container_end();

      ImGui::End();
  }
  ```

notes:
  Los flags `ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_FittingPolicyResizeDown` son fijos para estandarizar la experiencia de tabs en dashboards. Si se necesitan flags distintos usar `ImGui::BeginTabBar` directamente.

documentation:
  # tab_container

  Wrapper fino sobre `ImGui::BeginTabBar` / `EndTabBar` con flags de dashboard preconfigurados: `Reorderable` (arrastrar tabs) y `FittingPolicyResizeDown` (tabs se achican si no caben).

  API de cuatro funciones siguiendo el patron begin/end de ImGui. Siempre llamar `tab_container_end()` si `tab_container_begin()` retorno true. Siempre llamar `tab_container_tab_end()` dentro del bloque `if (tab_container_tab(...))`.

code:
  #include "core/tab_container.h"
  #include "imgui.h"

  bool tab_container_begin(const char* id) {
      return ImGui::BeginTabBar(
          id,
          ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_FittingPolicyResizeDown
      );
  }

  bool tab_container_tab(const char* label) {
      return ImGui::BeginTabItem(label);
  }

  void tab_container_tab_end() {
      ImGui::EndTabItem();
  }

  void tab_container_end() {
      ImGui::EndTabBar();
  }

params_schema:
  {"params":[{"name":"id","desc":"Identificador unico del tab bar (ej: '##views'). Puede ser invisible con prefijo ##"},{"name":"label","desc":"Etiqueta visible de cada tab individual"}],"output":"tab_container_begin: true si el tab bar esta activo. tab_container_tab: true si el tab esta seleccionado y se debe renderizar su contenido"}


id: time_series_buffer_cpp_core
name: time_series_buffer
kind: function
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: TimeSeriesBuffer(size_t capacity)
tags: ["buffer","timeseries","streaming","dashboard","data"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: cpp/functions/core/time_series_buffer.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: bf82ceb467c8e2bf492d9b9805f3562c7f7ab08c358c9babe336335522726c3d
description:
  Ring buffer circular para datos de series temporales, optimizado para streaming de metricas en dashboards en tiempo real

notes:
  - No depende de ImGui ni ImPlot directamente — es una estructura de datos pura.
  - No es thread-safe. Para uso multihilo, proteger con mutex externo.
  - Move semantics implementadas; copy queda eliminada (`= delete`) para evitar copias accidentales del heap.
  - `min`, `max` y `average` iteran sobre `count` elementos — O(n). Para buffers grandes en hot paths, considerar mantener un acumulador incremental.

documentation:
  # time_series_buffer

  Ring buffer circular de floats para series temporales en tiempo real. Diseñado para alimentar plots de ImPlot con metricas de streaming (FPS, latencia, uso de CPU, etc.).

  ## Uso basico

  ```cpp
  TimeSeriesBuffer fps_history(512);

  // En el loop principal:
  fps_history.push(ImGui::GetIO().Framerate);

  // Para renderizar con ImPlot:
  float ordered[512];
  size_t n = fps_history.copy_ordered(ordered, 512);
  ImPlot::PlotLines("FPS", ordered, (int)n);
  ```

  ## Semantica del ring buffer

  - `push(v)` escribe en `data[offset % capacity]` y avanza el write head.
  - `count` crece hasta `capacity` y se mantiene ahi — el buffer nunca desborda.
  - `get(0)` retorna el elemento mas antiguo; `get(count-1)` el mas reciente.
  - `latest()` es equivalente a `get(count-1)` pero mas explicito.
  - `copy_ordered` extrae los datos en orden cronologico para pasarlos a ImPlot u otro consumidor que espere un array contiguo.

code:
  #include "time_series_buffer.h"

  #include <algorithm>
  #include <cassert>
  #include <cstring>
  #include <limits>
  #include <numeric>
  #include <utility>

  TimeSeriesBuffer::TimeSeriesBuffer(size_t cap)
      : data(new float[cap]), capacity(cap), count(0), offset(0) {}

  TimeSeriesBuffer::~TimeSeriesBuffer() {
      delete[] data;
  }

  TimeSeriesBuffer::TimeSeriesBuffer(TimeSeriesBuffer&& other) noexcept
      : data(other.data), capacity(other.capacity), count(other.count), offset(other.offset) {
      other.data     = nullptr;
      other.capacity = 0;
      other.count    = 0;
      other.offset   = 0;
  }

  TimeSeriesBuffer& TimeSeriesBuffer::operator=(TimeSeriesBuffer&& other) noexcept {
      if (this != &other) {
          delete[] data;
          data           = other.data;
          capacity       = other.capacity;
          count          = other.count;
          offset         = other.offset;
          other.data     = nullptr;
          other.capacity = 0;
          other.count    = 0;
          other.offset   = 0;
      }
      return *this;
  }

  void TimeSeriesBuffer::push(float value) {
      data[offset % capacity] = value;
      offset++;
      if (count < capacity) count++;
  }

  float TimeSeriesBuffer::get(size_t index) const {
      assert(index < count);
      // oldest element is at (offset - count) % capacity
      size_t real = (offset - count + index) % capacity;
      return data[real];
  }

  float TimeSeriesBuffer::latest() const {
      assert(count > 0);
      return data[(offset - 1) % capacity];
  }

  float TimeSeriesBuffer::min() const {
      float m = std::numeric_limits<float>::max();
      for (size_t i = 0; i < count; ++i) m = std::min(m, get(i));
      return m;
  }

  float TimeSeriesBuffer::max() const {
      float m = std::numeric_limits<float>::lowest();
      for (size_t i = 0; i < count; ++i) m = std::max(m, get(i));
      return m;
  }

  float TimeSeriesBuffer::average() const {
      if (count == 0) return 0.0f;
      float sum = 0.0f;
      for (size_t i = 0; i < count; ++i) sum += get(i);
      return sum / static_cast<float>(count);
  }

  size_t TimeSeriesBuffer::size() const {
      return count;
  }

  bool TimeSeriesBuffer::full() const {
      return count == capacity;
  }

  size_t TimeSeriesBuffer::copy_ordered(float* out, size_t out_capacity) const {
      size_t n = std::min(count, out_capacity);
      for (size_t i = 0; i < n; ++i)
          out[i] = get(i);
      return n;
  }

params_schema:
  {"params":[{"name":"capacity","desc":"Numero maximo de muestras que almacena el buffer"}],"output":"Buffer circular listo para push de datos y lectura ordenada"}


id: tracy_zone_cpp_core
name: tracy_zone
kind: function
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: FN_ZONE(name) / FN_ZONE_COLOR(name, color) / FN_FRAME_MARK / FN_PLOT(name, val)
description: Macros y constantes de conveniencia para Tracy profiling zones, compilables sin Tracy
tags: ["tracy","profiling","debug","performance","raii"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: cpp/functions/core/tracy_zone.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: d97e1d27b60810d0c0ae4455f1e85d22d3ffa3b25cd2927535c48a76ffc12c91
example:
  ```cpp
  #include "core/tracy_zone.h"

  void render_scene() {
      FN_ZONE_COLOR("render_scene", fn_tracy::COLOR_RENDER);
      // ... render calls ...
  }

  void update(float dt) {
      FN_ZONE("update");
      // ... game logic ...
      FN_PLOT("dt_ms", dt * 1000.0f);
  }

  void main_loop() {
      while (running) {
          update(dt);
          render_scene();
          FN_FRAME_MARK;
      }
  }
  ```

notes:
  - Compilar con `-DTRACY_ENABLE` y enlazar `TracyClient.cpp` para activar profiling.
  - Sin `TRACY_ENABLE` el .cpp es prácticamente vacío — coste cero en producción.
  - Los colores son ARGB; si el alpha es 0 Tracy aplica su color automático por zona.
  - `FN_ZONE` expande a `ZoneScopedN(name)` de Tracy, que crea el contexto con `__LINE__` como discriminador — es seguro llamar varias veces en el mismo scope.

documentation:
  # tracy_zone

  Macros de scope para instrumentar secciones de código con Tracy Profiler. Cuando `TRACY_ENABLE` no está definido (ej. builds de producción) todas las macros se expanden a `(void)0`, sin coste alguno.

  ## Macros disponibles

  | Macro | Descripción |
  |---|---|
  | `FN_ZONE("name")` | Zona con nombre, color automático |
  | `FN_ZONE_COLOR("name", color)` | Zona con nombre y color ARGB explícito |
  | `FN_FRAME_MARK` | Marca el límite de frame (eje X de Tracy) |
  | `FN_PLOT("name", val)` | Envía un valor escalar al panel de plots |

  ## Colores predefinidos (`fn_tracy::`)

  ```cpp
  fn_tracy::COLOR_RENDER  // 0x2196F3 azul   — rendering
  fn_tracy::COLOR_UPDATE  // 0x4CAF50 verde  — game update / logic
  fn_tracy::COLOR_IO      // 0xFF9800 naranja — I/O disco/red
  fn_tracy::COLOR_NETWORK // 0xF44336 rojo   — red/HTTP
  fn_tracy::COLOR_COMPUTE // 0x9C27B0 morado — compute / shader prep
  ```

code:
  // tracy_zone.cpp
  // All definitions live in tracy_zone.h (macros + constexpr).
  // This translation unit exists so the header can be included in any build
  // without requiring Tracy — compile with -DTRACY_ENABLE to activate profiling.
  #include "core/tracy_zone.h"

params_schema:
  {"params":[{"name":"name","desc":"Nombre de la zona tal como aparece en la timeline de Tracy"},{"name":"color","desc":"Color ARGB uint32 de la zona en Tracy (opcional, solo FN_ZONE_COLOR)"}],"output":"Zona Tracy activa durante el scope actual; no-op cuando TRACY_ENABLE no está definido"}


id: bar_chart_cpp_viz
name: bar_chart
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Renderiza un grafico de barras verticales usando ImPlot dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","bar"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/bar_chart.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: f4b83230686eb4031c0bd4fcc3851aee0475dcc7e8baa1297e759c9356783d48
signature:
  void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width)

documentation:
  # bar_chart

  Wrapper atomico sobre `ImPlot::PlotBars` con configuracion automatica de etiquetas en el eje X.

  Debe llamarse dentro del render callback de `fn::run_app`.

code:
  #include "viz/bar_chart.h"
  #include "implot.h"

  #include <vector>

  void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          std::vector<double> positions(count);
          for (int i = 0; i < count; i++) positions[i] = i;

          ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, labels);
          ImPlot::PlotBars("##data", values, count, bar_width);
          ImPlot::EndPlot();
      }
  }

  void bar_chart(const char* title, const char* const* labels, const double* values, int count, double bar_width) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          std::vector<double> positions(count);
          for (int i = 0; i < count; i++) positions[i] = i;

          ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, labels);
          ImPlot::PlotBars("##data", values, count, bar_width);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico de barras"},{"name":"labels","desc":"Array de etiquetas para el eje X, una por barra"},{"name":"values","desc":"Array de valores numericos para la altura de cada barra"},{"name":"count","desc":"Numero de barras (longitud de labels y values)"},{"name":"bar_width","desc":"Ancho de cada barra como fraccion del espacio disponible (default 0.67)"}],"output":"Renderiza el grafico de barras en el frame ImGui actual"}


id: candlestick_cpp_viz
name: candlestick
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
tags: ["implot","chart","visualization","gpu","candlestick","ohlc","finance"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot","imgui"]
tests: []
file_path: cpp/functions/viz/candlestick.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: c21c6466b2b06e80e69f72790865b6f012153a8665c1da016f8ac97a6e5897d0
signature:
  void candlestick(const char* title, const double* dates, const double* opens, const double* closes, const double* lows, const double* highs, int count, float width_percent = 0.25f, bool tooltip = true)

description:
  Renderiza un grafico de velas OHLC usando ImPlot custom rendering. Verde para velas alcistas (close >= open), rojo para bajistas.

example:
  ```cpp
  // arrays de datos financieros (timestamps Unix, precios)
  candlestick("BTC/USD", dates, opens, closes, lows, highs, 90);

  // sin tooltip, velas mas anchas
  candlestick("ETH/USD", dates, opens, closes, lows, highs, 30, 0.6f, false);
  ```

notes:
  - El ancho de cada vela se calcula como `(dates[1] - dates[0]) * width_percent * 0.5` en cada lado. Asume spacing uniforme entre velas.
  - Para un solo punto (`count == 1`) el spacing por defecto es 1.0.
  - La deteccion de hover usa un margen de 4px alrededor del area cuerpo+wick para facilitar la interaccion.
  - El eje X usa `ImPlotScale_Time` — si los datos son indices numericos simples (0, 1, 2...) en lugar de timestamps, pasar `ImPlotAxisFlags_NoDecorations` o cambiar `SetupAxisScale`.

documentation:
  # candlestick

  Grafico de velas OHLC completo usando custom rendering de ImPlot. Dibuja body (open-close) y wicks (high-low) por vela usando `ImPlot::GetPlotDrawList()` y `ImPlot::PlotToPixels()` para conversion de coordenadas.

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo). El eje X se configura con `ImPlotScale_Time` para timestamps Unix.

  Solo tiene overload `double` porque los datos financieros requieren doble precision.

code:
  #include "viz/candlestick.h"
  #include "imgui.h"
  #include "implot.h"

  void candlestick(const char* title, const double* dates, const double* opens,
                   const double* closes, const double* lows, const double* highs,
                   int count, float width_percent, bool tooltip) {
      if (count <= 0) return;

      // Compute half-width of each candle body in data coordinates.
      // Use spacing between consecutive dates when count > 1, else fallback to 0.5.
      double spacing = (count > 1) ? (dates[1] - dates[0]) : 1.0;
      double half_w  = spacing * (double)width_percent * 0.5;

      ImPlot::SetupAxes("Date", "Price", ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit);
      ImPlot::SetupAxisScale(ImAxis_X1, ImPlotScale_Time);

      // Auto-fit X axis to the data range with a small margin.
      ImPlot::SetupAxisLimits(ImAxis_X1,
                              dates[0]         - spacing,
                              dates[count - 1] + spacing,
                              ImGuiCond_Always);

      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImDrawList* draw = ImPlot::GetPlotDrawList();

          const ImU32 col_bull = IM_COL32(0,  200, 80,  255);  // green — close >= open
          const ImU32 col_bear = IM_COL32(220, 50, 50,  255);  // red   — close < open

          int hovered_idx = -1;

          for (int i = 0; i < count; i++) {
              double x     = dates[i];
              double open  = opens[i];
              double close = closes[i];
              double low   = lows[i];
              double high  = highs[i];

              bool bullish = (close >= open);
              ImU32 col   = bullish ? col_bull : col_bear;

              // Convert data coordinates to screen pixels.
              ImVec2 body_tl = ImPlot::PlotToPixels(x - half_w, bullish ? close : open);
              ImVec2 body_br = ImPlot::PlotToPixels(x + half_w, bullish ? open  : close);
              ImVec2 wick_hi = ImPlot::PlotToPixels(x, high);
              ImVec2 wick_lo = ImPlot::PlotToPixels(x, low);
              float  cx      = (body_tl.x + body_br.x) * 0.5f;

              // Wick (high-low vertical line).
              draw->AddLine(ImVec2(cx, wick_hi.y), ImVec2(cx, wick_lo.y), col, 1.5f);

              // Body rectangle (open-close).
              // Ensure at least 1px height so flat candles are visible.
              if (body_br.y <= body_tl.y + 1.0f) body_br.y = body_tl.y + 1.0f;
              draw->AddRectFilled(body_tl, body_br, col);
              draw->AddRect(body_tl, body_br, col);

              // Track hovered candle for tooltip.
              if (tooltip && ImPlot::IsPlotHovered()) {
                  ImVec2 mouse = ImGui::GetMousePos();
                  if (mouse.x >= body_tl.x - 4 && mouse.x <= body_br.x + 4 &&
                      mouse.y >= wick_hi.y  - 4 && mouse.y <= wick_lo.y  + 4) {
                      hovered_idx = i;
                  }
              }
          }

          // Tooltip for the hovered candle.
          if (tooltip && hovered_idx >= 0) {
              int i = hovered_idx;
              ImGui::BeginTooltip();
              ImGui::Text("O: %.4f", opens[i]);
              ImGui::Text("H: %.4f", highs[i]);
              ImGui::Text("L: %.4f", lows[i]);
              ImGui::Text("C: %.4f", closes[i]);
              ImGui::EndTooltip();
          }

          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico, se muestra como header del plot"},{"name":"dates","desc":"Array de timestamps Unix o indices numericos del eje X, uno por vela"},{"name":"opens","desc":"Array de precios de apertura, uno por vela"},{"name":"closes","desc":"Array de precios de cierre, uno por vela"},{"name":"lows","desc":"Array de precios minimos (punta inferior del wick), uno por vela"},{"name":"highs","desc":"Array de precios maximos (punta superior del wick), uno por vela"},{"name":"count","desc":"Numero de velas (longitud de todos los arrays)"},{"name":"width_percent","desc":"Ancho del body de cada vela como fraccion del espacio entre puntos consecutivos (0.0-1.0, default 0.25)"},{"name":"tooltip","desc":"Si true, muestra tooltip con valores O/H/L/C al hacer hover sobre una vela"}],"output":"Renderiza el grafico de velas OHLC en el frame ImGui actual, sin retornar valor"}


id: gauge_cpp_viz
name: gauge
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: void gauge(const char* label, float value, float min_val, float max_val, float radius = 60.0f)
description: Renderiza un indicador circular tipo gauge/velocimetro usando ImGui draw primitives
tags: ["imgui","visualization","gauge","kpi","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/gauge.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 585a6c8133bbb20d80773f10639191b4fbb118ac7496940a7745f3e06b01b86a
example:
  ```cpp
  // KPI card con gauge de temperatura
  gauge("CPU Temp", 72.5f, 0.0f, 100.0f, 50.0f);

  // Gauge grande para dashboard principal
  gauge("Velocidad", 3200.0f, 0.0f, 5000.0f, 80.0f);
  ```

notes:
  - El arco de fondo es gris oscuro (IM_COL32(60,60,60,220)), 6px de grosor.
  - La aguja tiene longitud del 75% del radio para evitar solapar el arco.
  - Usa solo `float`; no ofrece overload `double` porque ImGui DrawList trabaja en coordenadas de pantalla (float).
  - El espacio reservado es `diameter x (diameter + line_height)` para incluir la etiqueta.

documentation:
  # gauge

  Indicador circular tipo gauge/velocimetro construido sobre ImGui draw primitives. No requiere ImPlot.

  El arco ocupa 240 grados (de 150deg a 390deg en sentido horario). El color del arco de valor interpolado de verde (minimo) a amarillo (mitad) a rojo (maximo). Una aguja blanca apunta al valor actual.

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

code:
  #include "viz/gauge.h"
  #include "imgui.h"
  #include <cmath>
  #include <cstdio>

  #ifndef M_PI
  #define M_PI 3.14159265358979323846f
  #endif

  void gauge(const char* label, float value, float min_val, float max_val, float radius) {
      ImDrawList* draw_list = ImGui::GetWindowDrawList();
      ImVec2 pos = ImGui::GetCursorScreenPos();

      // Reserve space: diameter + label line
      float diameter = radius * 2.0f;
      ImGui::Dummy(ImVec2(diameter, diameter + ImGui::GetTextLineHeightWithSpacing()));

      ImVec2 center = ImVec2(pos.x + radius, pos.y + radius);

      // Arc spans 240 degrees: from 150deg to 390deg (i.e. 150 to 30 going clockwise)
      // In screen space Y is down, so angles go clockwise.
      // Start angle: 150 degrees = bottom-left, End angle: 390 = 30 degrees = bottom-right
      const float angle_start = (150.0f * (float)M_PI) / 180.0f;
      const float angle_end   = (390.0f * (float)M_PI) / 180.0f;
      const int   num_segments = 64;

      // Background arc (dark gray)
      ImU32 bg_color = IM_COL32(60, 60, 60, 220);
      for (int i = 0; i < num_segments; i++) {
          float a0 = angle_start + (angle_end - angle_start) * ((float)i       / num_segments);
          float a1 = angle_start + (angle_end - angle_start) * ((float)(i + 1) / num_segments);
          draw_list->AddLine(
              ImVec2(center.x + cosf(a0) * radius, center.y + sinf(a0) * radius),
              ImVec2(center.x + cosf(a1) * radius, center.y + sinf(a1) * radius),
              bg_color, 6.0f);
      }

      // Normalize value to [0, 1]
      float t = 0.0f;
      if (max_val > min_val) {
          t = (value - min_val) / (max_val - min_val);
          if (t < 0.0f) t = 0.0f;
          if (t > 1.0f) t = 1.0f;
      }

      // Color: green (t=0) -> yellow (t=0.5) -> red (t=1)
      float r, g, b;
      if (t < 0.5f) {
          float s = t * 2.0f;
          r = (unsigned char)(s * 255);
          g = 200;
          b = 0;
      } else {
          float s = (t - 0.5f) * 2.0f;
          r = 220;
          g = (unsigned char)((1.0f - s) * 200);
          b = 0;
      }
      ImU32 value_color = IM_COL32((int)r, (int)g, (int)b, 255);

      // Value arc
      float angle_value = angle_start + (angle_end - angle_start) * t;
      int value_segments = (int)(num_segments * t);
      for (int i = 0; i < value_segments; i++) {
          float a0 = angle_start + (angle_end - angle_start) * ((float)i       / num_segments);
          float a1 = angle_start + (angle_end - angle_start) * ((float)(i + 1) / num_segments);
          draw_list->AddLine(
              ImVec2(center.x + cosf(a0) * radius, center.y + sinf(a0) * radius),
              ImVec2(center.x + cosf(a1) * radius, center.y + sinf(a1) * radius),
              value_color, 6.0f);
      }

      // Needle: line from center to arc at current angle
      float needle_len = radius * 0.75f;
      ImVec2 needle_tip = ImVec2(
          center.x + cosf(angle_value) * needle_len,
          center.y + sinf(angle_value) * needle_len);
      draw_list->AddLine(center, needle_tip, IM_COL32(255, 255, 255, 240), 2.0f);
      draw_list->AddCircleFilled(center, 4.0f, IM_COL32(255, 255, 255, 200));

      // Value text centered
      char val_buf[32];
      snprintf(val_buf, sizeof(val_buf), "%.1f", value);
      ImVec2 val_size = ImGui::CalcTextSize(val_buf);
      draw_list->AddText(
          ImVec2(center.x - val_size.x * 0.5f, center.y + radius * 0.35f),
          IM_COL32(230, 230, 230, 255), val_buf);

      // Label below value
      ImVec2 label_size = ImGui::CalcTextSize(label);
      draw_list->AddText(
          ImVec2(center.x - label_size.x * 0.5f, center.y + radius * 0.35f + val_size.y + 2.0f),
          IM_COL32(180, 180, 180, 200), label);
  }

params_schema:
  {"params":[{"name":"label","desc":"Etiqueta del gauge, se muestra centrada debajo del valor numerico"},{"name":"value","desc":"Valor actual a mostrar en el gauge"},{"name":"min_val","desc":"Valor minimo de la escala (extremo izquierdo del arco)"},{"name":"max_val","desc":"Valor maximo de la escala (extremo derecho del arco)"},{"name":"radius","desc":"Radio del gauge en pixels (default 60.0)"}],"output":"Renderiza el gauge en el frame ImGui actual, reservando espacio con ImGui::Dummy"}


id: graph_force_layout_cpp_viz
name: graph_force_layout
kind: function
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config)
tags: ["graph","layout","force-directed","barnes-hut","physics","gpu"]
uses_functions: []
uses_types: ["GraphData_cpp_viz"]
returns: []
imports: []
tests: []
file_path: cpp/functions/viz/graph_force_layout.cpp
created_at: 2026-04-07T22:05:15Z
updated_at: 2026-04-07T22:05:15Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 9311ea971afaf855d46541d53f54f62c1e6e92f8232d28eee5deb1fd506dff4f
description:
  Layout force-directed con aproximacion Barnes-Hut para grafos grandes, ejecuta un paso de simulacion por llamada

example:
  ```cpp
  static ForceLayoutConfig cfg;
  static bool running = true;

  if (running) {
      float energy = graph_force_layout_step(my_graph, cfg);
      if (energy < 0.01f) running = false; // convergido
  }
  ```

notes:
  - El quadtree usa un pool estatico de `1 << 20` (~1M) celdas. Para grafos de >500K nodos
    se recomienda reducir `MAX_QUAD_NODES` o aumentarlo segun memoria disponible.
  - La pila de traversal en `quad_force` es tambien estatica (`static int stack[]`); no es
    thread-safe si se llama desde multiples hilos simultaneamente.
  - `graph_force_layout_reset` usa `rand()`. Para reproducibilidad llama `srand(seed)` antes.
  - Los buffers de fuerza (`fx_buf`, `fy_buf`) se realocan una sola vez cuando el conteo de
    nodos supera la capacidad previa; en el uso normal (tamano fijo) no hay allocaciones
    por frame.

documentation:
  # graph_force_layout

  Implementa el algoritmo de layout force-directed clasico (Fruchterman-Reingold / Eades) con aproximacion Barnes-Hut O(n log n) para escalar a grafos de miles de nodos.

  ## Algoritmo

  Cada llamada a `graph_force_layout_step` ejecuta `config.iterations` pasos. Un paso:

  1. **Construccion del quadtree** (Barnes-Hut): se calcula el bounding box de las posiciones actuales, se construye un quadtree flat en `quad_pool` (sin allocaciones por nodo). Cada celda acumula centro de masa y masa total.
  2. **Repulsion**: para cada nodo se recorre el quadtree. Si el cociente `cell_size / distance < theta`, la celda se trata como una sola masa puntual (multipolo de orden 0). Si no, se desciende a los hijos. Con `theta=0` es O(n²) exacto; con `theta=0.5` es O(n log n).
  3. **Atraccion**: para cada arista `(s, t)`, fuerza de Hooke `F = k * dist * weight` en la direccion del arco.
  4. **Gravedad**: fuerza proporcional a la distancia al origen, evita que el grafo derive fuera de pantalla.
  5. **Integracion**: `v = v * damping + F`, `pos += v`, con clamping de velocidad.
  6. Nodos con `pinned = true` no se mueven en ningun paso.

  ## Funciones auxiliares

  ```cpp
  // Randomizar posiciones para empezar la simulacion
  graph_force_layout_reset(graph, 200.0f);

  // Layout circular instantaneo (sin iteracion)
  graph_layout_circular(graph, 150.0f);

  // Layout en grid instantaneo
  graph_layout_grid(graph, 25.0f);
  ```

code:
  #include "viz/graph_force_layout.h"
  #include "viz/graph_types.h"

  #include <cmath>
  #include <cstdlib>
  #include <algorithm>

  // ---------------------------------------------------------------------------
  // Quadtree for Barnes-Hut approximation
  // ---------------------------------------------------------------------------

  struct QuadNode {
      float cx, cy;       // center of mass
      float mass;         // total mass (node count in subtree)
      float x0, y0;       // bounding box min
      float x1, y1;       // bounding box max
      int children[4];    // NW=0, NE=1, SW=2, SE=3  (-1 = empty)
      int body;           // node index if leaf (-1 if internal)
  };

  static constexpr int MAX_QUAD_NODES = 1 << 20; // supports graphs up to ~1M nodes
  static QuadNode quad_pool[MAX_QUAD_NODES];
  static int quad_count = 0;

  static int quad_new(float x0, float y0, float x1, float y1) {
      if (quad_count >= MAX_QUAD_NODES) return -1;
      int idx = quad_count++;
      QuadNode& q = quad_pool[idx];
      q.cx = 0; q.cy = 0; q.mass = 0;
      q.x0 = x0; q.y0 = y0; q.x1 = x1; q.y1 = y1;
      q.children[0] = q.children[1] = q.children[2] = q.children[3] = -1;
      q.body = -1;
      return idx;
  }

  // Determine quadrant index for point (px,py) relative to cell midpoint.
  // 0=NW, 1=NE, 2=SW, 3=SE
  static int quad_child_idx(const QuadNode& q, float px, float py) {
      float mx = (q.x0 + q.x1) * 0.5f;
      float my = (q.y0 + q.y1) * 0.5f;
      int xi = (px >= mx) ? 1 : 0;
      int yi = (py >= my) ? 2 : 0;
      return xi | yi;
  }

  // Subdivide cell qi into four children.
  static void quad_subdivide(int qi) {
      QuadNode& q = quad_pool[qi];
      float mx = (q.x0 + q.x1) * 0.5f;
      float my = (q.y0 + q.y1) * 0.5f;
      // NW
      quad_pool[qi].children[0] = quad_new(q.x0, q.y0, mx, my);
      // NE
      quad_pool[qi].children[1] = quad_new(mx, q.y0, q.x1, my);
      // SW
      quad_pool[qi].children[2] = quad_new(q.x0, my, mx, q.y1);
      // SE
      quad_pool[qi].children[3] = quad_new(mx, my, q.x1, q.y1);
  }

  // Insert body (node_idx at position nx,ny with mass nmass) into cell qi.
  // Uses iterative descent to avoid stack overflow on deep trees.
  static void quad_insert(int root, int node_idx, float nx, float ny, float nmass) {
      int qi = root;
      while (qi >= 0) {
          QuadNode& q = quad_pool[qi];
          // Update center of mass
          float total = q.mass + nmass;
          q.cx = (q.cx * q.mass + nx * nmass) / total;
          q.cy = (q.cy * q.mass + ny * nmass) / total;
          q.mass = total;

          if (q.body == -1 && q.children[0] == -1) {
              // Empty leaf: place body here
              q.body = node_idx;
              return;
          }

          if (q.body >= 0) {
              // Leaf with existing body: subdivide, push existing body down
              quad_subdivide(qi);
              // Move old body into correct child (re-read q after subdivide since pool may shift)
              QuadNode& qq = quad_pool[qi];
              int old_body = qq.body;
              float obx = /* we need positions */ 0, oby = 0;
              // We store positions in the GraphData, pass via closure is not possible here.
              // Instead we pass a pointer to positions alongside. We'll fix this by using
              // a file-scope pointer set before each build.
              (void)old_body; (void)obx; (void)oby;
              // NOTE: positions accessed via file-scope g_nodes pointer below.
              qq.body = -1;
          }

          int ci = quad_child_idx(quad_pool[qi], nx, ny);
          qi = quad_pool[qi].children[ci];
      }
  }

  // File-scope pointers set before each tree build (avoids passing them everywhere).
  static const GraphNode* g_nodes = nullptr;

  // Insert body knowing positions from g_nodes.
  static void quad_insert_body(int qi, int node_idx) {
      float nx = g_nodes[node_idx].x;
      float ny = g_nodes[node_idx].y;
      const float nmass = 1.0f;

      while (qi >= 0) {
          QuadNode& q = quad_pool[qi];
          float total = q.mass + nmass;
          q.cx = (q.cx * q.mass + nx * nmass) / total;
          q.cy = (q.cy * q.mass + ny * nmass) / total;
          q.mass = total;

          if (q.body == -1 && q.children[0] == -1) {
              // Empty leaf
              q.body = node_idx;
              return;
          }

          if (q.children[0] == -1) {
              // Leaf occupied: subdivide and push existing body down
              int old_body = q.body;
              q.body = -1;
              quad_subdivide(qi);

              // Push old body into child
              int old_ci = quad_child_idx(quad_pool[qi], g_nodes[old_body].x, g_nodes[old_body].y);
              int old_child = quad_pool[qi].children[old_ci];
              if (old_child >= 0) {
                  QuadNode& oc = quad_pool[old_child];
                  oc.cx = g_nodes[old_body].x;
                  oc.cy = g_nodes[old_body].y;
                  oc.mass = 1.0f;
                  oc.body = old_body;
              }
          }

          int ci = quad_child_idx(quad_pool[qi], nx, ny);
          qi = quad_pool[qi].children[ci];
      }
  }

  // Compute Barnes-Hut repulsion force on node at (nx,ny) from subtree qi.
  // Accumulates force into (fx, fy).
  static void quad_force(int qi, float nx, float ny,
                         float theta, float repulsion, float min_dist,
                         float& fx, float& fy) {
      // Iterative traversal using a small stack to avoid recursion depth issues.
      static int stack[MAX_QUAD_NODES]; // reuse static stack
      int top = 0;
      stack[top++] = qi;

      while (top > 0) {
          int ci = stack[--top];
          if (ci < 0) continue;
          const QuadNode& q = quad_pool[ci];
          if (q.mass == 0) continue;

          float dx = q.cx - nx;
          float dy = q.cy - ny;
          float dist2 = dx * dx + dy * dy;
          float dist = std::sqrt(dist2);
          if (dist < min_dist) dist = min_dist;

          // Cell size
          float cell_size = q.x1 - q.x0;

          // Use multipole approximation if far enough OR if leaf
          bool is_leaf = (q.children[0] == -1);
          if (is_leaf || (cell_size / dist) < theta) {
              // Coulomb repulsion: F = repulsion * mass / dist^2
              float force = repulsion * q.mass / (dist * dist);
              fx -= force * dx / dist;
              fy -= force * dy / dist;
          } else {
              // Push children
              for (int k = 0; k < 4; ++k) {
                  if (q.children[k] >= 0)
                      stack[top++] = q.children[k];
              }
          }
      }
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config) {
      if (graph.node_count <= 0) return 0.0f;

      // Temporary force accumulators (stack-allocated for small graphs, static for large)
      static float* fx_buf = nullptr;
      static float* fy_buf = nullptr;
      static int    buf_cap = 0;

      if (graph.node_count > buf_cap) {
          delete[] fx_buf;
          delete[] fy_buf;
          buf_cap = graph.node_count + 64;
          fx_buf = new float[buf_cap];
          fy_buf = new float[buf_cap];
      }

      float total_energy = 0.0f;

      for (int iter = 0; iter < config.iterations; ++iter) {
          // Zero forces
          for (int i = 0; i < graph.node_count; ++i) {
              fx_buf[i] = 0.0f;
              fy_buf[i] = 0.0f;
          }

          // ---- Build Barnes-Hut quadtree ----
          // Compute bounding box of current positions
          float bx0 = graph.nodes[0].x, bx1 = graph.nodes[0].x;
          float by0 = graph.nodes[0].y, by1 = graph.nodes[0].y;
          for (int i = 1; i < graph.node_count; ++i) {
              float px = graph.nodes[i].x, py = graph.nodes[i].y;
              if (px < bx0) bx0 = px; if (px > bx1) bx1 = px;
              if (py < by0) by0 = py; if (py > by1) by1 = py;
          }
          // Add margin to avoid degeneracies
          float margin = (bx1 - bx0 + by1 - by0) * 0.05f + 1.0f;
          bx0 -= margin; bx1 += margin;
          by0 -= margin; by1 += margin;
          // Make it square
          float side = std::max(bx1 - bx0, by1 - by0);
          float cx = (bx0 + bx1) * 0.5f, cy = (by0 + by1) * 0.5f;
          bx0 = cx - side * 0.5f; bx1 = cx + side * 0.5f;
          by0 = cy - side * 0.5f; by1 = cy + side * 0.5f;

          quad_count = 0;
          g_nodes = graph.nodes;
          int root = quad_new(bx0, by0, bx1, by1);

          for (int i = 0; i < graph.node_count; ++i) {
              quad_insert_body(root, i);
          }

          // ---- Repulsion via Barnes-Hut ----
          for (int i = 0; i < graph.node_count; ++i) {
              if (graph.nodes[i].pinned) continue;
              quad_force(root,
                         graph.nodes[i].x, graph.nodes[i].y,
                         config.theta, config.repulsion, config.min_distance,
                         fx_buf[i], fy_buf[i]);
              // Subtract self-interaction (the tree includes the node itself)
              // Self-force: repulsion * 1 / min_dist^2, but direction is (0,0) -> skip
          }

          // ---- Attraction along edges (spring force) ----
          for (int e = 0; e < graph.edge_count; ++e) {
              const GraphEdge& edge = graph.edges[e];
              int s = (int)edge.source;
              int t = (int)edge.target;
              if (s < 0 || s >= graph.node_count) continue;
              if (t < 0 || t >= graph.node_count) continue;

              float dx = graph.nodes[t].x - graph.nodes[s].x;
              float dy = graph.nodes[t].y - graph.nodes[s].y;
              float dist = std::sqrt(dx * dx + dy * dy);
              if (dist < config.min_distance) dist = config.min_distance;

              // F = k * dist * weight  (Hooke: pulls toward equilibrium at 0)
              float force = config.attraction * dist * edge.weight;
              float fx_e = force * dx / dist;
              float fy_e = force * dy / dist;

              if (!graph.nodes[s].pinned) { fx_buf[s] += fx_e; fy_buf[s] += fy_e; }
              if (!graph.nodes[t].pinned) { fx_buf[t] -= fx_e; fy_buf[t] -= fy_e; }
          }

          // ---- Gravity toward center (0,0) ----
          if (config.gravity != 0.0f) {
              for (int i = 0; i < graph.node_count; ++i) {
                  if (graph.nodes[i].pinned) continue;
                  fx_buf[i] -= config.gravity * graph.nodes[i].x;
                  fy_buf[i] -= config.gravity * graph.nodes[i].y;
              }
          }

          // ---- Integrate: v = v * damping + F; pos += v ----
          total_energy = 0.0f;
          for (int i = 0; i < graph.node_count; ++i) {
              GraphNode& n = graph.nodes[i];
              if (n.pinned) continue;

              n.vx = n.vx * config.damping + fx_buf[i];
              n.vy = n.vy * config.damping + fy_buf[i];

              // Clamp velocity
              n.vx = std::max(-config.max_velocity, std::min(config.max_velocity, n.vx));
              n.vy = std::max(-config.max_velocity, std::min(config.max_velocity, n.vy));

              n.x += n.vx;
              n.y += n.vy;

              total_energy += n.vx * n.vx + n.vy * n.vy;
          }
      }

      graph.update_bounds();
      return total_energy;
  }

  void graph_force_layout_reset(GraphData& graph, float spread) {
      for (int i = 0; i < graph.node_count; ++i) {
          GraphNode& n = graph.nodes[i];
          if (n.pinned) continue;
          // rand() produces [0, RAND_MAX]; map to [-spread, spread]
          n.x = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f);
          n.y = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f);
          n.vx = 0.0f;
          n.vy = 0.0f;
      }
      graph.update_bounds();
  }

  void graph_layout_circular(GraphData& graph, float radius) {
      if (graph.node_count <= 0) return;
      const float two_pi = 6.28318530718f;
      for (int i = 0; i < graph.node_count; ++i) {
          GraphNode& n = graph.nodes[i];
          if (n.pinned) continue;
          float angle = two_pi * (float)i / (float)graph.node_count;
          n.x = radius * std::cos(angle);
          n.y = radius * std::sin(angle);
          n.vx = 0.0f;
          n.vy = 0.0f;
      }
      graph.update_bounds();
  }

  void graph_layout_grid(GraphData& graph, float spacing) {
      if (graph.node_count <= 0) return;
      int cols = (int)std::ceil(std::sqrt((float)graph.node_count));
      int rows = (graph.node_count + cols - 1) / cols;
      float ox = -0.5f * (cols - 1) * spacing;
      float oy = -0.5f * (rows - 1) * spacing;
      for (int i = 0; i < graph.node_count; ++i) {
          GraphNode& n = graph.nodes[i];
          if (n.pinned) continue;
          int col = i % cols;
          int row = i / cols;
          n.x = ox + col * spacing;
          n.y = oy + row * spacing;
          n.vx = 0.0f;
          n.vy = 0.0f;
      }
      graph.update_bounds();
  }

params_schema:
  {"params":[{"name":"graph","desc":"Referencia al grafo (GraphData) cuyos nodos se actualizan in-place. Modifica x, y, vx, vy de cada nodo no pinned."},{"name":"config","desc":"Parametros de la simulacion: repulsion (fuerza coulombiana), attraction (spring constant), damping (decay de velocidad), theta (precision Barnes-Hut 0=exacto/1=rapido), gravity (atraccion al centro), max_velocity, iterations."}],"output":"Energia cinetica total (suma de |v|^2). Cuando cae por debajo de un umbral elegido por el caller, el layout ha convergido y se puede dejar de llamar."}


id: graph_renderer_cpp_viz
name: graph_renderer
kind: function
lang: cpp
domain: viz
version: 1.0.0
purity: impure
signature: GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config)
tags: ["graph","renderer","opengl","gpu","instanced","fbo","visualization"]
uses_functions: []
uses_types: ["GraphData_cpp_viz"]
returns: []
error_type: error_go_core
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/graph_renderer.cpp
created_at: 2026-04-07T22:05:15Z
updated_at: 2026-04-07T22:05:15Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 454b3d858672beb05e1fe34e5c7691ea1cb09ea8f72fe370645e6d6a67960d27
description:
  Renderer GPU de grafos con instanced rendering a FBO, compatible con ImGui::Image para visualizacion de grafos grandes

example:
  ```cpp
  // Inicializacion (una vez)
  GraphRenderer* renderer = graph_renderer_create(800, 600);

  // En el render loop
  ImVec2 panel_size = ImGui::GetContentRegionAvail();
  graph_renderer_resize(renderer, (int)panel_size.x, (int)panel_size.y);

  unsigned int tex = graph_renderer_draw(renderer, graph_data,
                                          cam_x, cam_y, cam_zoom);

  ImGui::Image((ImTextureID)(uintptr_t)tex,
               panel_size,
               ImVec2(0, 1), ImVec2(1, 0)); // flip Y para OpenGL

  // Destruccion
  graph_renderer_destroy(renderer);
  ```

notes:
  **Renderizado de nodos:** Instanced rendering con un quad unitario [-0.5, 0.5] expandido por el tamano del nodo. El fragment shader aplica un SDF circular con anti-aliasing via `smoothstep` y un anillo de outline.

  **Renderizado de aristas:** `GL_LINES` con datos de posicion y color empaquetados por arista. El ancho se controla con `GraphRendererConfig::edge_width`.

  **Transformacion de camara:**
  ```
  tx = -cam_x * zoom + width/2
  ty = -cam_y * zoom + height/2
  ndc = (screen / viewport) * 2 - 1
  ```

  **Paleta de comunidades:** 10 colores ABGR usados cuando `node.color == 0`, seleccionados por `node.community % 10`.

  **Estado GL:** Guarda y restaura `GL_FRAMEBUFFER_BINDING` y `GL_VIEWPORT` para ser compatible con el render loop de ImGui sin efectos secundarios.

  **Includes GL:** Usa `#define GL_GLEXT_PROTOTYPES` + `<GL/gl.h>` + `<GL/glext.h>`. Si el proyecto carga funciones GL via glad/gl3w, reemplazar estos includes por el loader correspondiente.

documentation:
  # graph_renderer

  Renderer GPU de grafos basado en OpenGL 3.3 core profile con instanced rendering. Renderiza nodos y aristas de un `GraphData` a un FBO interno y retorna el texture ID para integracion directa con `ImGui::Image()`.

  ## Funciones del API

  ```cpp
  // Ciclo de vida
  GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config = {});
  void graph_renderer_destroy(GraphRenderer* r);
  void graph_renderer_resize(GraphRenderer* r, int width, int height);

  // Renderizado
  unsigned int graph_renderer_draw(GraphRenderer* r, const GraphData& graph,
                                    float cam_x, float cam_y, float cam_zoom);
  ```

code:
  #include "viz/graph_renderer.h"
  #include "viz/graph_types.h"

  #define GL_GLEXT_PROTOTYPES
  #include <GL/gl.h>
  #include <GL/glext.h>

  #include <cstdlib>
  #include <cstring>
  #include <cstdio>
  #include <cmath>

  // ---------------------------------------------------------------------------
  // Community palette (ABGR packed, 10 colors)
  // ---------------------------------------------------------------------------
  static const uint32_t k_palette[10] = {
      0xFF4CAF50, // green
      0xFFF44336, // red
      0xFF2196F3, // blue
      0xFFFF9800, // orange
      0xFF9C27B0, // purple
      0xFF00BCD4, // cyan
      0xFFFFEB3B, // yellow
      0xFFE91E63, // pink
      0xFF795548, // brown
      0xFF607D8B  // blue-grey
  };

  // ---------------------------------------------------------------------------
  // Internal struct
  // ---------------------------------------------------------------------------
  struct GraphRenderer {
      unsigned int fbo;
      unsigned int texture;
      unsigned int rbo; // depth/stencil renderbuffer
      int width, height;

      // Node rendering (instanced quads)
      unsigned int node_vao, node_quad_vbo, node_instance_vbo;
      unsigned int node_shader;

      // Edge rendering (lines)
      unsigned int edge_vao, edge_vbo;
      unsigned int edge_shader;

      GraphRendererConfig config;
  };

  // ---------------------------------------------------------------------------
  // Shader sources
  // ---------------------------------------------------------------------------

  // Node vertex shader — instanced unit quad
  static const char* k_node_vert = R"(
  #version 330 core
  // Quad corners [-0.5, 0.5]
  layout(location = 0) in vec2 a_quad;

  // Per-instance: world position, size, RGBA color
  layout(location = 1) in vec2  a_pos;
  layout(location = 2) in float a_size;
  layout(location = 3) in vec4  a_color;

  out vec2  v_uv;
  out vec4  v_color;

  uniform vec2  u_viewport;   // (width, height) in pixels
  uniform float u_scale;      // cam_zoom
  uniform vec2  u_translate;  // (tx, ty) in pixels

  void main() {
      // World -> screen (pixels)
      vec2 screen = a_pos * u_scale + u_translate;
      // Expand quad by node radius (size = diameter)
      screen += a_quad * a_size * u_scale;
      // Screen -> NDC
      vec2 ndc = (screen / u_viewport) * 2.0 - 1.0;
      ndc.y = -ndc.y; // flip Y (screen Y grows downward)
      gl_Position = vec4(ndc, 0.0, 1.0);
      v_uv    = a_quad + 0.5; // [0,1]
      v_color = a_color;
  }
  )";

  // Node fragment shader — SDF circle with outline
  static const char* k_node_frag = R"(
  #version 330 core
  in vec2 v_uv;
  in vec4 v_color;

  out vec4 frag_color;

  uniform float u_outline_px;  // outline width in uv units
  uniform float u_node_px;     // node diameter in pixels (= size * zoom)

  void main() {
      // SDF circle centered at (0.5, 0.5) in uv space
      float dist = length(v_uv - 0.5);
      float r    = 0.5;

      // Anti-alias edge (in uv units; 1px ~ 1/u_node_px in uv space)
      float fwidth_uv = 1.5 / max(u_node_px, 1.0);

      float alpha = 1.0 - smoothstep(r - fwidth_uv, r, dist);
      if (alpha < 0.001) discard;

      // Outline ring
      float outline_uv = u_outline_px / max(u_node_px, 1.0);
      float outline    = smoothstep(r - outline_uv - fwidth_uv, r - outline_uv, dist);

      vec3 fill    = v_color.rgb;
      vec3 outline_col = mix(fill, vec3(1.0), 0.6); // lighter outline
      vec3 color   = mix(fill, outline_col, outline);

      frag_color = vec4(color, v_color.a * alpha);
  }
  )";

  // Edge vertex shader
  static const char* k_edge_vert = R"(
  #version 330 core
  layout(location = 0) in vec2 a_pos;
  layout(location = 1) in vec4 a_color;

  out vec4 v_color;

  uniform vec2  u_viewport;
  uniform float u_scale;
  uniform vec2  u_translate;

  void main() {
      vec2 screen = a_pos * u_scale + u_translate;
      vec2 ndc    = (screen / u_viewport) * 2.0 - 1.0;
      ndc.y = -ndc.y;
      gl_Position = vec4(ndc, 0.0, 1.0);
      v_color = a_color;
  }
  )";

  // Edge fragment shader
  static const char* k_edge_frag = R"(
  #version 330 core
  in vec4 v_color;
  out vec4 frag_color;

  void main() {
      frag_color = v_color;
  }
  )";

  // ---------------------------------------------------------------------------
  // Shader helpers
  // ---------------------------------------------------------------------------
  static unsigned int compile_shader(GLenum type, const char* src) {
      unsigned int s = glCreateShader(type);
      glShaderSource(s, 1, &src, nullptr);
      glCompileShader(s);
      int ok;
      glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
      if (!ok) {
          char buf[512];
          glGetShaderInfoLog(s, sizeof(buf), nullptr, buf);
          fprintf(stderr, "[graph_renderer] shader compile error: %s\n", buf);
      }
      return s;
  }

  static unsigned int link_program(const char* vert_src, const char* frag_src) {
      unsigned int vs = compile_shader(GL_VERTEX_SHADER,   vert_src);
      unsigned int fs = compile_shader(GL_FRAGMENT_SHADER, frag_src);
      unsigned int prog = glCreateProgram();
      glAttachShader(prog, vs);
      glAttachShader(prog, fs);
      glLinkProgram(prog);
      int ok;
      glGetProgramiv(prog, GL_LINK_STATUS, &ok);
      if (!ok) {
          char buf[512];
          glGetProgramInfoLog(prog, sizeof(buf), nullptr, buf);
          fprintf(stderr, "[graph_renderer] program link error: %s\n", buf);
      }
      glDeleteShader(vs);
      glDeleteShader(fs);
      return prog;
  }

  // ---------------------------------------------------------------------------
  // FBO helpers
  // ---------------------------------------------------------------------------
  static void create_fbo(GraphRenderer* r) {
      // Texture
      glGenTextures(1, &r->texture);
      glBindTexture(GL_TEXTURE_2D, r->texture);
      glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, r->width, r->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
      glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
      glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
      glBindTexture(GL_TEXTURE_2D, 0);

      // Depth renderbuffer
      glGenRenderbuffers(1, &r->rbo);
      glBindRenderbuffer(GL_RENDERBUFFER, r->rbo);
      glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, r->width, r->height);
      glBindRenderbuffer(GL_RENDERBUFFER, 0);

      // FBO
      glGenFramebuffers(1, &r->fbo);
      glBindFramebuffer(GL_FRAMEBUFFER, r->fbo);
      glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, r->texture, 0);
      glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, r->rbo);
      glBindFramebuffer(GL_FRAMEBUFFER, 0);
  }

  static void destroy_fbo(GraphRenderer* r) {
      glDeleteFramebuffers(1,  &r->fbo);
      glDeleteTextures(1,      &r->texture);
      glDeleteRenderbuffers(1, &r->rbo);
      r->fbo = r->texture = r->rbo = 0;
  }

  // ---------------------------------------------------------------------------
  // Helper: unpack ABGR uint32 to float RGBA
  // ---------------------------------------------------------------------------
  static inline void abgr_to_rgba(uint32_t abgr, float& r, float& g, float& b, float& a) {
      // ABGR layout: bits 31-24 = A, 23-16 = B, 15-8 = G, 7-0 = R
      a = ((abgr >> 24) & 0xFF) / 255.0f;
      b = ((abgr >> 16) & 0xFF) / 255.0f;
      g = ((abgr >>  8) & 0xFF) / 255.0f;
      r = ((abgr      ) & 0xFF) / 255.0f;
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config) {
      GraphRenderer* r = new GraphRenderer();
      r->width  = width;
      r->height = height;
      r->config = config;

      // --- FBO ---
      create_fbo(r);

      // --- Node VAO ---
      // Unit quad: 4 vertices, each (x, y) in [-0.5, 0.5]
      static const float quad_verts[8] = {
          -0.5f, -0.5f,
           0.5f, -0.5f,
          -0.5f,  0.5f,
           0.5f,  0.5f,
      };

      glGenVertexArrays(1, &r->node_vao);
      glBindVertexArray(r->node_vao);

      // Quad VBO (location 0)
      glGenBuffers(1, &r->node_quad_vbo);
      glBindBuffer(GL_ARRAY_BUFFER, r->node_quad_vbo);
      glBufferData(GL_ARRAY_BUFFER, sizeof(quad_verts), quad_verts, GL_STATIC_DRAW);
      glEnableVertexAttribArray(0);
      glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);

      // Instance VBO (location 1,2,3 — position, size, color)
      glGenBuffers(1, &r->node_instance_vbo);
      glBindBuffer(GL_ARRAY_BUFFER, r->node_instance_vbo);
      // layout: x, y, size, r, g, b, a  — 7 floats per instance
      glEnableVertexAttribArray(1); // pos
      glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)0);
      glVertexAttribDivisor(1, 1);
      glEnableVertexAttribArray(2); // size
      glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)(2 * sizeof(float)));
      glVertexAttribDivisor(2, 1);
      glEnableVertexAttribArray(3); // color
      glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)(3 * sizeof(float)));
      glVertexAttribDivisor(3, 1);

      glBindVertexArray(0);

      // --- Edge VAO ---
      // Each edge: 2 vertices x (x, y, r, g, b, a) = 2 * 6 floats
      glGenVertexArrays(1, &r->edge_vao);
      glBindVertexArray(r->edge_vao);

      glGenBuffers(1, &r->edge_vbo);
      glBindBuffer(GL_ARRAY_BUFFER, r->edge_vbo);
      glEnableVertexAttribArray(0); // pos
      glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
      glEnableVertexAttribArray(1); // color
      glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(2 * sizeof(float)));

      glBindVertexArray(0);

      // --- Shaders ---
      r->node_shader = link_program(k_node_vert, k_node_frag);
      r->edge_shader = link_program(k_edge_vert, k_edge_frag);

      return r;
  }

  void graph_renderer_destroy(GraphRenderer* r) {
      if (!r) return;
      destroy_fbo(r);
      glDeleteVertexArrays(1, &r->node_vao);
      glDeleteBuffers(1, &r->node_quad_vbo);
      glDeleteBuffers(1, &r->node_instance_vbo);
      glDeleteVertexArrays(1, &r->edge_vao);
      glDeleteBuffers(1, &r->edge_vbo);
      glDeleteProgram(r->node_shader);
      glDeleteProgram(r->edge_shader);
      delete r;
  }

  void graph_renderer_resize(GraphRenderer* r, int width, int height) {
      if (!r) return;
      if (r->width == width && r->height == height) return;
      r->width  = width;
      r->height = height;
      destroy_fbo(r);
      create_fbo(r);
  }

  unsigned int graph_renderer_draw(GraphRenderer* r, const GraphData& graph,
                                    float cam_x, float cam_y, float cam_zoom) {
      if (!r) return 0;

      // --- Save GL state ---
      GLint prev_fbo;
      glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prev_fbo);
      GLint prev_viewport[4];
      glGetIntegerv(GL_VIEWPORT, prev_viewport);

      // --- Bind FBO ---
      glBindFramebuffer(GL_FRAMEBUFFER, r->fbo);
      glViewport(0, 0, r->width, r->height);

      // Clear with bg_color (ABGR)
      float bg_a, bg_b, bg_g, bg_cr;
      abgr_to_rgba(r->config.bg_color, bg_cr, bg_g, bg_b, bg_a);
      glClearColor(bg_cr, bg_g, bg_b, bg_a);
      glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

      // Enable blending for anti-aliasing and transparency
      glEnable(GL_BLEND);
      glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

      // View transform: world -> screen pixels
      // tx = -cam_x * scale + width/2
      // ty = -cam_y * scale + height/2
      float scale = cam_zoom;
      float tx = -cam_x * scale + (float)r->width  * 0.5f;
      float ty = -cam_y * scale + (float)r->height * 0.5f;

      // ----------------------------------------------------------------
      // Draw edges
      // ----------------------------------------------------------------
      if (graph.edge_count > 0 && graph.edges && graph.nodes) {
          // Pack: 2 vertices per edge, each vertex = (x, y, r, g, b, a) = 6 floats
          const int floats_per_edge = 2 * 6;
          float* edge_buf = (float*)malloc((size_t)graph.edge_count * floats_per_edge * sizeof(float));
          int vi = 0;
          for (int i = 0; i < graph.edge_count; ++i) {
              const GraphEdge& e = graph.edges[i];
              uint32_t ecol = e.color != 0 ? e.color : 0xFF888888u; // default gray
              float er, eg, eb, ea;
              abgr_to_rgba(ecol, er, eg, eb, ea);
              ea *= r->config.edge_alpha;

              if (e.source < (uint32_t)graph.node_count && e.target < (uint32_t)graph.node_count) {
                  const GraphNode& ns = graph.nodes[e.source];
                  const GraphNode& nt = graph.nodes[e.target];

                  // Source vertex
                  edge_buf[vi++] = ns.x; edge_buf[vi++] = ns.y;
                  edge_buf[vi++] = er;   edge_buf[vi++] = eg;
                  edge_buf[vi++] = eb;   edge_buf[vi++] = ea;
                  // Target vertex
                  edge_buf[vi++] = nt.x; edge_buf[vi++] = nt.y;
                  edge_buf[vi++] = er;   edge_buf[vi++] = eg;
                  edge_buf[vi++] = eb;   edge_buf[vi++] = ea;
              }
          }

          glUseProgram(r->edge_shader);
          glUniform2f(glGetUniformLocation(r->edge_shader, "u_viewport"), (float)r->width, (float)r->height);
          glUniform1f(glGetUniformLocation(r->edge_shader, "u_scale"),    scale);
          glUniform2f(glGetUniformLocation(r->edge_shader, "u_translate"), tx, ty);

          glLineWidth(r->config.edge_width);

          glBindVertexArray(r->edge_vao);
          glBindBuffer(GL_ARRAY_BUFFER, r->edge_vbo);
          glBufferData(GL_ARRAY_BUFFER, vi * (int)sizeof(float), edge_buf, GL_DYNAMIC_DRAW);
          glDrawArrays(GL_LINES, 0, vi / 6);
          glBindVertexArray(0);

          free(edge_buf);
      }

      // ----------------------------------------------------------------
      // Draw nodes (instanced quads)
      // ----------------------------------------------------------------
      if (graph.node_count > 0 && graph.nodes) {
          // Pack: 7 floats per node: x, y, size, r, g, b, a
          float* node_buf = (float*)malloc((size_t)graph.node_count * 7 * sizeof(float));
          for (int i = 0; i < graph.node_count; ++i) {
              const GraphNode& n = graph.nodes[i];
              uint32_t ncol = n.color != 0 ? n.color : k_palette[n.community % 10];
              float nr, ng, nb, na;
              abgr_to_rgba(ncol, nr, ng, nb, na);

              float sz = n.size > 0.0f ? n.size : 4.0f;
              float* p = node_buf + i * 7;
              p[0] = n.x; p[1] = n.y; p[2] = sz;
              p[3] = nr;  p[4] = ng;  p[5] = nb; p[6] = na;
          }

          glUseProgram(r->node_shader);
          glUniform2f(glGetUniformLocation(r->node_shader, "u_viewport"),  (float)r->width, (float)r->height);
          glUniform1f(glGetUniformLocation(r->node_shader, "u_scale"),     scale);
          glUniform2f(glGetUniformLocation(r->node_shader, "u_translate"), tx, ty);
          glUniform1f(glGetUniformLocation(r->node_shader, "u_outline_px"), r->config.node_outline);

          glBindVertexArray(r->node_vao);
          glBindBuffer(GL_ARRAY_BUFFER, r->node_instance_vbo);
          glBufferData(GL_ARRAY_BUFFER, graph.node_count * 7 * (int)sizeof(float), node_buf, GL_DYNAMIC_DRAW);

          // Draw 4 vertices (triangle strip quad) x node_count instances
          // Pass per-instance node_px uniform via the average size (approximation)
          // For exact per-node pixel size we'd need a texture or another approach;
          // use a uniform average for AA quality — good enough for most graphs.
          float avg_px = 8.0f * scale; // rough estimate
          glUniform1f(glGetUniformLocation(r->node_shader, "u_node_px"), avg_px);

          glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, graph.node_count);
          glBindVertexArray(0);

          free(node_buf);
      }

      // --- Restore GL state ---
      glDisable(GL_BLEND);
      glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo);
      glViewport(prev_viewport[0], prev_viewport[1], prev_viewport[2], prev_viewport[3]);

      return r->texture;
  }

params_schema:
  {"params":[{"name":"width","desc":"Ancho del framebuffer en pixels"},{"name":"height","desc":"Alto del framebuffer en pixels"},{"name":"config","desc":"Configuracion visual: outline width, edge width, edge alpha, color de fondo, fade de aristas por distancia a camara"}],"output":"Handle opaco al renderer. Usar graph_renderer_draw() para obtener texture ID de OpenGL, pasable directamente a ImGui::Image()"}


id: graph_viewport_cpp_viz
name: graph_viewport
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: impure
signature: bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, ImVec2 size)
tags: ["graph","viewport","imgui","interactive","pan","zoom","dashboard"]
uses_functions: ["graph_renderer_cpp_viz","graph_force_layout_cpp_viz","graph_spatial_hash_cpp_core"]
uses_types: ["GraphData_cpp_viz"]
returns: []
error_type: error_go_core
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/graph_viewport.cpp
created_at: 2026-04-07T22:05:15Z
updated_at: 2026-04-07T22:05:15Z
emits: []
framework: imgui
variant: []
content_hash: 724d590828b6a821060c7dee9eb04d38f36dcf9811fd6674045c9dfd83312e85
description:
  Widget ImGui completo para visualizacion interactiva de grafos con pan, zoom, hover, seleccion y layout en vivo

props:
  [{"name":"id","type":"const char*","required":true,"description":"Identificador unico del widget ImGui"},{"name":"graph","type":"GraphData\u0026","required":true,"description":"Referencia al grafo (lectura de datos, escritura de posiciones al drag)"},{"name":"state","type":"GraphViewportState\u0026","required":true,"description":"Estado persistente del viewport (camera, seleccion, renderer). Debe vivir mas que los frames."},{"name":"size","type":"ImVec2","required":false,"description":"Tamanio del widget en pixeles. ImVec2(0,0) usa todo el espacio disponible."}]

notes:
  - Usa `ImGui::InvisibleButton` con flags para los tres botones del raton, capturando input sin dibujar ningun boton visible.
  - La textura del renderer se muestra con UV volteado en Y (`ImVec2(0,1)` a `ImVec2(1,0)`) para corregir la convencion de coordenadas de OpenGL vs ImGui.
  - El spatial hash se reconstruye cada frame desde las posiciones actuales de los nodos, garantizando hit-testing correcto despues de drag o layout.
  - El zoom hacia el cursor mantiene el punto del grafo bajo el cursor fijo en pantalla ajustando `cam_x`/`cam_y`.

documentation:
  # graph_viewport

  Widget ImGui self-contained para visualizar grafos interactivos. Integra rendering GPU, force-directed layout y hit-testing espacial en una sola llamada por frame.

  ## Uso basico

  ```cpp
  // Declarar estado persistente (fuera del loop de render)
  GraphViewportState vp_state;

  // En el loop de render (dentro de una ventana ImGui):
  if (graph_viewport("mi_grafo", my_graph, vp_state)) {
      // hubo interaccion este frame
      if (vp_state.selected_node >= 0) {
          auto& n = my_graph.nodes[vp_state.selected_node];
          // mostrar panel de detalle de n
      }
  }

  // Al terminar:
  graph_viewport_destroy(vp_state);
  ```

  ## Estado de camara

  La camara usa coordenadas del espacio del grafo:

  - `cam_x`, `cam_y`: centro de la camara en espacio del grafo
  - `zoom`: pixeles por unidad de grafo

  `graph_viewport_fit()` centra y ajusta el zoom para que el grafo quepa con 10% de padding.

  ## Controles

  | Accion | Control |
  |--------|---------|
  | Pan | Boton medio o derecho + arrastrar |
  | Zoom | Rueda del raton (hacia el cursor) |
  | Seleccionar nodo | Click izquierdo |
  | Arrastrar nodo | Click izquierdo sobre nodo |
  | Toggle layout | Barra espaciadora |
  | Fit camara | F |

  ## Force layout

  El layout se ejecuta automaticamente cada frame mientras `state.layout_running == true`. Se detiene solo cuando la energia cinetica cae por debajo de `0.01`. Se puede pausar/reanudar con la barra espaciadora.

  Los nodos arrastrados se marcan como `pinned = true` durante el drag, impidiendo que el force layout los mueva. Al soltar, `pinned` vuelve a `false`.

  ## Tooltip

  Al hacer hover sobre un nodo se muestra un tooltip con: label, id numerico, community, degree (aristas conectadas) y value.

  ## Status bar

  En la parte inferior del widget aparece: numero de nodos, aristas, zoom actual, energia del layout y recordatorio de atajos de teclado.

  ## Inicializacion lazy

  El renderer OpenGL y el spatial hash se crean en el primer frame. La camara se ajusta automaticamente con `graph_viewport_fit` en la inicializacion.

code:
  #include "viz/graph_viewport.h"
  #include "viz/graph_types.h"
  #include "viz/graph_renderer.h"
  #include "viz/graph_force_layout.h"
  #include "core/graph_spatial_hash.h"
  #include "imgui.h"

  #include <cstdio>   // snprintf
  #include <cstring>  // memset
  #include <vector>

  // ---------------------------------------------------------------------------
  // Internal helpers
  // ---------------------------------------------------------------------------

  static void viewport_to_graph(float vx, float vy,
                                 float widget_x, float widget_y,
                                 float widget_w, float widget_h,
                                 float cam_x, float cam_y, float zoom,
                                 float& gx, float& gy)
  {
      gx = (vx - widget_x - widget_w * 0.5f) / zoom + cam_x;
      gy = (vy - widget_y - widget_h * 0.5f) / zoom + cam_y;
  }

  // ---------------------------------------------------------------------------
  // graph_viewport_fit
  // ---------------------------------------------------------------------------

  void graph_viewport_fit(GraphData& graph, GraphViewportState& state)
  {
      graph.update_bounds();
      if (graph.node_count == 0) {
          state.cam_x = 0.0f;
          state.cam_y = 0.0f;
          state.zoom  = 1.0f;
          return;
      }

      float cx = (graph.min_x + graph.max_x) * 0.5f;
      float cy = (graph.min_y + graph.max_y) * 0.5f;
      state.cam_x = cx;
      state.cam_y = cy;

      float span_x = graph.max_x - graph.min_x;
      float span_y = graph.max_y - graph.min_y;
      float span   = (span_x > span_y ? span_x : span_y);

      // Use render dimensions if available; fall back to a safe default.
      float view_px = (state.render_w > 0 ? (float)state.render_w : 600.0f);
      float view_py = (state.render_h > 0 ? (float)state.render_h : 400.0f);
      float view_min = (view_px < view_py ? view_px : view_py);

      if (span > 0.0f) {
          state.zoom = (view_min * 0.9f) / span;
      } else {
          state.zoom = 1.0f;
      }

      // Clamp to allowed range
      if (state.zoom < state.zoom_min) state.zoom = state.zoom_min;
      if (state.zoom > state.zoom_max) state.zoom = state.zoom_max;
  }

  // ---------------------------------------------------------------------------
  // graph_viewport_destroy
  // ---------------------------------------------------------------------------

  void graph_viewport_destroy(GraphViewportState& state)
  {
      if (state.renderer) {
          graph_renderer_destroy(state.renderer);
          state.renderer = nullptr;
      }
      if (state.spatial) {
          delete state.spatial;
          state.spatial = nullptr;
      }
      state.initialized = false;
  }

  // ---------------------------------------------------------------------------
  // graph_viewport — main widget
  // ---------------------------------------------------------------------------

  bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
                      ImVec2 size)
  {
      bool interacted = false;

      // Resolve size
      ImVec2 avail = ImGui::GetContentRegionAvail();
      float w = (size.x > 0.0f) ? size.x : avail.x;
      float h = (size.y > 0.0f) ? size.y : avail.y;
      if (w < 1.0f) w = 1.0f;
      if (h < 1.0f) h = 1.0f;

      int iw = (int)w, ih = (int)h;

      // -------------------------------------------------------------------
      // 1. Lazy init
      // -------------------------------------------------------------------
      if (!state.initialized) {
          state.renderer  = graph_renderer_create(iw, ih);
          state.spatial   = new SpatialHash(20.0f, 4096);
          state.render_w  = iw;
          state.render_h  = ih;
          state.initialized = true;
          graph_viewport_fit(graph, state);
      }

      // -------------------------------------------------------------------
      // 2. Resize
      // -------------------------------------------------------------------
      if (iw != state.render_w || ih != state.render_h) {
          graph_renderer_resize(state.renderer, iw, ih);
          state.render_w = iw;
          state.render_h = ih;
      }

      // -------------------------------------------------------------------
      // 3. Force layout step
      // -------------------------------------------------------------------
      if (state.layout_running && graph.node_count > 0) {
          state.layout_energy = graph_force_layout_step(graph);
          if (state.layout_energy < 0.01f) {
              state.layout_running = false;
          }
      }

      // -------------------------------------------------------------------
      // 4. Build spatial hash
      // -------------------------------------------------------------------
      if (graph.node_count > 0) {
          static std::vector<float> xs_buf, ys_buf, sz_buf;
          xs_buf.resize(graph.node_count);
          ys_buf.resize(graph.node_count);
          sz_buf.resize(graph.node_count);
          for (int i = 0; i < graph.node_count; ++i) {
              xs_buf[i] = graph.nodes[i].x;
              ys_buf[i] = graph.nodes[i].y;
              sz_buf[i] = graph.nodes[i].size;
          }
          state.spatial->build(xs_buf.data(), ys_buf.data(), sz_buf.data(), graph.node_count);
      }

      // -------------------------------------------------------------------
      // 5. Invisible button to capture input
      // -------------------------------------------------------------------
      ImGui::PushID(id);

      ImVec2 widget_pos = ImGui::GetCursorScreenPos();
      ImGui::InvisibleButton("canvas", ImVec2(w, h),
                             ImGuiButtonFlags_MouseButtonLeft  |
                             ImGuiButtonFlags_MouseButtonMiddle|
                             ImGuiButtonFlags_MouseButtonRight);

      bool hovered  = ImGui::IsItemHovered();
      bool lm_down  = ImGui::IsMouseDown(ImGuiMouseButton_Left);
      bool lm_click = ImGui::IsMouseClicked(ImGuiMouseButton_Left);
      bool mm_down  = ImGui::IsMouseDown(ImGuiMouseButton_Middle);
      bool rm_down  = ImGui::IsMouseDown(ImGuiMouseButton_Right);

      ImVec2 mouse_pos = ImGui::GetMousePos();
      float  mx = mouse_pos.x, my = mouse_pos.y;

      // Convert mouse to graph space
      float gx_mouse, gy_mouse;
      viewport_to_graph(mx, my,
                        widget_pos.x, widget_pos.y, w, h,
                        state.cam_x, state.cam_y, state.zoom,
                        gx_mouse, gy_mouse);

      // -------------------------------------------------------------------
      // 5a. Pan (middle or right mouse drag)
      // -------------------------------------------------------------------
      if (hovered && (mm_down || rm_down)) {
          ImVec2 delta = ImGui::GetIO().MouseDelta;
          if (delta.x != 0.0f || delta.y != 0.0f) {
              state.cam_x -= delta.x / state.zoom;
              state.cam_y -= delta.y / state.zoom;
              interacted = true;
          }
      }

      // -------------------------------------------------------------------
      // 5b. Zoom (scroll wheel)
      // -------------------------------------------------------------------
      if (hovered) {
          float wheel = ImGui::GetIO().MouseWheel;
          if (wheel != 0.0f) {
              float old_zoom = state.zoom;
              float new_zoom = old_zoom * (1.0f + wheel * 0.1f);
              if (new_zoom < state.zoom_min) new_zoom = state.zoom_min;
              if (new_zoom > state.zoom_max) new_zoom = state.zoom_max;

              // Zoom toward cursor: keep gx_mouse/gy_mouse fixed in graph space
              float rel_x = (mx - widget_pos.x - w * 0.5f);
              float rel_y = (my - widget_pos.y - h * 0.5f);
              state.cam_x += rel_x / old_zoom - rel_x / new_zoom;
              state.cam_y += rel_y / old_zoom - rel_y / new_zoom;
              state.zoom   = new_zoom;
              interacted   = true;
          }
      }

      // -------------------------------------------------------------------
      // 5c. Hover — query nearest node
      // -------------------------------------------------------------------
      state.hovered_node = -1;
      if (hovered && graph.node_count > 0) {
          float hit_radius = 10.0f / state.zoom;
          int nearest = state.spatial->query_nearest(gx_mouse, gy_mouse, hit_radius);
          if (nearest >= 0) {
              state.hovered_node = nearest;
              interacted = true;
          }
      }

      // -------------------------------------------------------------------
      // 5d. Node drag (left mouse down on a node)
      // -------------------------------------------------------------------
      if (hovered && lm_down) {
          if (state.drag_node == -1 && state.hovered_node >= 0) {
              state.drag_node   = state.hovered_node;
              state.is_dragging = true;
          }
      } else {
          // Release drag
          if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
              graph.nodes[state.drag_node].pinned = false;
          }
          state.drag_node   = -1;
          state.is_dragging = false;
      }

      if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
          GraphNode& n = graph.nodes[state.drag_node];
          n.x      = gx_mouse;
          n.y      = gy_mouse;
          n.vx     = 0.0f;
          n.vy     = 0.0f;
          n.pinned = true;
          interacted = true;
      }

      // -------------------------------------------------------------------
      // 5e. Click — select node
      // -------------------------------------------------------------------
      if (hovered && lm_click && state.drag_node == -1) {
          state.selected_node = state.hovered_node;
          interacted = true;
      }

      // -------------------------------------------------------------------
      // 5f. Keyboard shortcuts (only when widget is active/hovered)
      // -------------------------------------------------------------------
      if (hovered) {
          if (ImGui::IsKeyPressed(ImGuiKey_Space)) {
              state.layout_running = !state.layout_running;
          }
          if (ImGui::IsKeyPressed(ImGuiKey_F)) {
              graph_viewport_fit(graph, state);
              interacted = true;
          }
      }

      // -------------------------------------------------------------------
      // 6. Render to GPU texture
      // -------------------------------------------------------------------
      unsigned int tex_id = graph_renderer_draw(state.renderer, graph,
                                                state.cam_x, state.cam_y,
                                                state.zoom);

      // -------------------------------------------------------------------
      // 7. Display texture (flip UV for OpenGL FBO convention)
      // -------------------------------------------------------------------
      ImDrawList* draw_list = ImGui::GetWindowDrawList();
      draw_list->AddImage(
          (ImTextureID)(intptr_t)tex_id,
          widget_pos,
          ImVec2(widget_pos.x + w, widget_pos.y + h),
          ImVec2(0.0f, 1.0f),   // UV top-left   (flipped Y)
          ImVec2(1.0f, 0.0f)    // UV bottom-right
      );

      // -------------------------------------------------------------------
      // 8. Tooltip on hovered node
      // -------------------------------------------------------------------
      if (state.hovered_node >= 0 && state.hovered_node < graph.node_count) {
          const GraphNode& n = graph.nodes[state.hovered_node];

          // Count degree
          int degree = 0;
          for (int i = 0; i < graph.edge_count; ++i) {
              if ((int)graph.edges[i].source == state.hovered_node ||
                  (int)graph.edges[i].target == state.hovered_node) {
                  ++degree;
              }
          }

          ImGui::BeginTooltip();
          if (n.label) ImGui::TextUnformatted(n.label);
          ImGui::Text("ID: %u", n.id);
          ImGui::Text("Community: %u", n.community);
          ImGui::Text("Degree: %d", degree);
          ImGui::Text("Value: %.3f", n.value);
          ImGui::EndTooltip();
      }

      // -------------------------------------------------------------------
      // 9. Status bar overlay
      // -------------------------------------------------------------------
      {
          char status[128];
          snprintf(status, sizeof(status),
                   "Nodes: %d | Edges: %d | Zoom: %.2fx | Energy: %.4f | [Space] layout [F] fit",
                   graph.node_count, graph.edge_count,
                   state.zoom, state.layout_energy);

          ImVec2 text_pos = ImVec2(widget_pos.x + 6.0f, widget_pos.y + h - 18.0f);
          draw_list->AddText(text_pos, IM_COL32(180, 180, 180, 200), status);
      }

      ImGui::PopID();
      return interacted;
  }

params_schema:
  {"params":[{"name":"id","desc":"Identificador unico del widget ImGui. Debe ser estable entre frames."},{"name":"graph","desc":"Grafo a visualizar. Las posiciones de nodos se modifican al arrastrar."},{"name":"state","desc":"Estado persistente: camara (cam_x, cam_y, zoom), nodo seleccionado/hovereado, renderer GPU, spatial hash. Alojado por el caller."},{"name":"size","desc":"Tamanio del widget en pixeles. (0,0) ocupa todo el espacio disponible en la ventana ImGui."}],"output":"true si hubo alguna interaccion del usuario en el frame actual (hover, click, drag, zoom, teclado)"}


id: heatmap_cpp_viz
name: heatmap
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Renderiza un mapa de calor 2D usando ImPlot dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","heatmap","matrix"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/heatmap.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: c89f29bd604bbed08bd575ac034f814bd8aab8e96108670f1f1a735512079945
signature:
  void heatmap(const char* title, const float* values, int rows, int cols, float scale_min, float scale_max)

documentation:
  # heatmap

  Wrapper atomico sobre `ImPlot::PlotHeatmap`. Renderiza una matriz de valores como mapa de calor con escala de color.

  Los datos deben estar en formato row-major. Si `scale_min` y `scale_max` son ambos 0, ImPlot autodetecta el rango.

code:
  #include "viz/heatmap.h"
  #include "implot.h"

  void heatmap(const char* title, const float* values, int rows, int cols,
               float scale_min, float scale_max) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_NoLegend)) {
          ImPlot::SetupAxes(nullptr, nullptr,
              ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
          ImPlot::PlotHeatmap("##data", values, rows, cols,
                              scale_min, scale_max);
          ImPlot::EndPlot();
      }
  }

  void heatmap(const char* title, const double* values, int rows, int cols,
               double scale_min, double scale_max) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_NoLegend)) {
          ImPlot::SetupAxes(nullptr, nullptr,
              ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
          ImPlot::PlotHeatmap("##data", values, rows, cols,
                              scale_min, scale_max);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del heatmap"},{"name":"values","desc":"Array de valores en orden row-major (values[row * cols + col])"},{"name":"rows","desc":"Numero de filas de la matriz"},{"name":"cols","desc":"Numero de columnas de la matriz"},{"name":"scale_min","desc":"Valor minimo de la escala de color (0 para autodetectar)"},{"name":"scale_max","desc":"Valor maximo de la escala de color (0 para autodetectar)"}],"output":"Renderiza el heatmap en el frame ImGui actual"}


id: histogram_cpp_viz
name: histogram
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: void histogram(const char* title, const float* values, int count, int bins = -1)
tags: ["implot","chart","visualization","gpu","histogram","distribution"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/histogram.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 48df96a635bfb690826e054e985cd330ea33d6498a24ab8d3bd45b5a67024982
description:
  Renderiza un histograma con bins automaticos o manuales usando ImPlot PlotHistogram dentro de un frame ImGui

documentation:
  # histogram

  Wrapper atomico sobre `ImPlot::PlotHistogram` con seleccion automatica del numero de bins.

  Cuando `bins == -1` usa `ImPlotBin_Sturges`, que calcula el numero de bins como `ceil(log2(n)) + 1`. Para distribuciones con muchos valores o alta varianza puede preferirse pasar un valor explicito.

  El plot usa `ImVec2(-1, 0)` para ocupar el ancho disponible con altura automatica.

  Debe llamarse dentro del render callback de `fn::run_app`.

code:
  #include "viz/histogram.h"
  #include "implot.h"

  void histogram(const char* title, const float* values, int count, int bins) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          int b = (bins > 0) ? bins : ImPlotBin_Sturges;
          ImPlot::PlotHistogram("##data", values, count, b);
          ImPlot::EndPlot();
      }
  }

  void histogram(const char* title, const double* values, int count, int bins) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          int b = (bins > 0) ? bins : ImPlotBin_Sturges;
          ImPlot::PlotHistogram("##data", values, count, b);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del histograma mostrado como cabecera del plot"},{"name":"values","desc":"Array de valores numericos a distribuir en bins"},{"name":"count","desc":"Numero de valores en el array"},{"name":"bins","desc":"Numero de bins. -1 = automatico via regla de Sturges (ImPlotBin_Sturges). Positivo = numero explicito de bins"}],"output":"Renderiza el histograma en el frame ImGui actual"}


id: kpi_card_cpp_viz
name: kpi_card
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Card de KPI con valor grande, delta porcentual, y sparkline historico para dashboards
tags: ["imgui","kpi","card","dashboard","metrics","sparkline"]
uses_functions: ["sparkline_cpp_viz"]
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/kpi_card.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: e226b3d68227148a0fbac12c2e36243fda7743a99a57a0b0d66da645e6523996
signature:
  void kpi_card(const char* label, float value, float delta_percent, const float* history = nullptr, int history_count = 0, const char* format = "%.1f")

example:
  ```cpp
  float history[] = {10.0f, 12.0f, 11.0f, 15.0f, 18.0f, 17.0f, 20.0f};
  kpi_card("Revenue", 20000.0f, 12.5f, history, 7, "$%.0f");

  // Sin sparkline
  kpi_card("Error Rate", 0.3f, -15.2f, nullptr, 0, "%.2f%%");

  // Grid de KPIs
  ImGui::Columns(3, "kpis", false);
  kpi_card("MAU",     1250000.0f,  3.4f, mau_history, 30);
  ImGui::NextColumn();
  kpi_card("Revenue", 89400.0f,   -1.2f, rev_history, 30, "$%.0f");
  ImGui::NextColumn();
  kpi_card("Churn",   2.1f,       -0.3f, churn_history, 30, "%.1f%%");
  ImGui::Columns(1);
  ```

notes:
  - El ancho total del grupo es aproximadamente 150px, apto para grids de 2-4 columnas.
  - El escalado de fuente usa `SetWindowFontScale(1.8f)` — compatible con cualquier fuente cargada; no requiere fonts adicionales.
  - Los caracteres UTF-8 del triangulo (`▲` U+25B2 y `▼` U+25BC) requieren que la fuente ImGui tenga el rango de simbolos geometricos cargado, o bien sustituir por ASCII (`^` y `v`).
  - El color verde del delta es `ImVec4(0.20, 0.80, 0.35, 1.0)` y el rojo `ImVec4(0.90, 0.25, 0.25, 1.0)`, coherentes con los colores del sparkline subyacente.
  - `BeginGroup`/`EndGroup` permite usar `SameLine()` despues de `kpi_card` y que el cursor avance correctamente.

documentation:
  # kpi_card

  Card compacta para dashboards ImGui que muestra un KPI con contexto de tendencia. Combina label, valor escalado, badge de delta colorizado y sparkline historico en un grupo coherente de ~150px de ancho.

  Usa `sparkline` del registry para el historico, con el mismo color que el badge (verde si delta >= 0, rojo si delta < 0).

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

code:
  #include "kpi_card.h"
  #include "sparkline.h"
  #include <imgui.h>
  #include <cstdio>

  void kpi_card(const char* label, float value, float delta_percent,
                const float* history, int history_count,
                const char* format) {
      ImGui::BeginGroup();

      // Label — small, muted
      ImGui::TextDisabled("%s", label);

      // Value — scaled up font
      ImGui::SetWindowFontScale(1.8f);
      char value_buf[64];
      snprintf(value_buf, sizeof(value_buf), format, value);
      ImGui::Text("%s", value_buf);
      ImGui::SetWindowFontScale(1.0f);

      // Delta badge — green up arrow / red down arrow
      const bool positive = delta_percent >= 0.0f;
      const ImVec4 delta_color = positive
          ? ImVec4(0.20f, 0.80f, 0.35f, 1.0f)   // green
          : ImVec4(0.90f, 0.25f, 0.25f, 1.0f);  // red

      char delta_buf[32];
      if (positive) {
          snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xb2 +%.1f%%", delta_percent);
      } else {
          snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xbc %.1f%%", delta_percent);
      }

      ImGui::PushStyleColor(ImGuiCol_Text, delta_color);
      ImGui::Text("%s", delta_buf);
      ImGui::PopStyleColor();

      // Sparkline — matches delta color
      if (history != nullptr && history_count > 0) {
          sparkline(label, history, history_count, delta_color, 120.0f, 24.0f);
      }

      ImGui::EndGroup();
  }

params_schema:
  {"params":[{"name":"label","desc":"Nombre del KPI mostrado como header muted (ej: \"Revenue\", \"Users\")"},{"name":"value","desc":"Valor numerico actual del KPI"},{"name":"delta_percent","desc":"Cambio porcentual respecto al periodo anterior (positivo = mejora, negativo = deterioro)"},{"name":"history","desc":"Array de valores historicos para el sparkline. Nullable — si es nullptr no se renderiza sparkline"},{"name":"history_count","desc":"Numero de valores en el array history"},{"name":"format","desc":"Formato printf para el valor principal (ej: \"$%.0f\", \"%.1f%%\", \"%.2f\")"}],"output":"Renderiza la card KPI completa en el frame ImGui actual: label muted, valor grande, badge delta verde/rojo con triangulo, y sparkline de 120x24px"}


id: line_plot_cpp_viz
name: line_plot
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: void line_plot(const char* title, const float* xs, const float* ys, int count)
description: Renderiza un grafico de lineas 2D usando ImPlot dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","line"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/line_plot.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 0d1dcfcea545da3a000a003dbd7cfdde13494e8ecad1cc8dd16f02cc938133cb
documentation:
  # line_plot

  Wrapper atomico sobre `ImPlot::PlotLine`. Renderiza un grafico de lineas 2D con los datos proporcionados.

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

  Soporta `float` y `double` precision.

code:
  #include "viz/line_plot.h"
  #include "implot.h"

  void line_plot(const char* title, const float* xs, const float* ys, int count) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImPlot::PlotLine("##data", xs, ys, count);
          ImPlot::EndPlot();
      }
  }

  void line_plot(const char* title, const double* xs, const double* ys, int count) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImPlot::PlotLine("##data", xs, ys, count);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico, se muestra como header del plot"},{"name":"xs","desc":"Array de coordenadas X"},{"name":"ys","desc":"Array de coordenadas Y"},{"name":"count","desc":"Numero de puntos en los arrays xs/ys"}],"output":"Renderiza el grafico de lineas en el frame ImGui actual"}


id: pie_chart_cpp_viz
name: pie_chart
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Renderiza un grafico circular (pie/donut) usando ImPlot PlotPieChart dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","pie","donut"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/pie_chart.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T22:21:10Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 144b3ead5db0e04a676cd844619816224cffb78053a175e2d2fa37e19f2f55ce
signature:
  void pie_chart(const char* title, const char* const* labels, const float* values, int count, float radius = 0.0f)

documentation:
  # pie_chart

  Wrapper atomico sobre `ImPlot::PlotPieChart` con soporte para modo pie y modo donut.

  El eje del plot se configura con `ImPlotAxisFlags_NoDecorations` para ocultar los ejes y mostrar solo el grafico circular. El aspecto se fuerza a cuadrado con `ImPlotFlags_Equal`.

  **Modo pie** (`radius >= 0`): dibuja un pie chart solido. Si `radius == 0`, usa radio automatico de 0.4.

  **Modo donut** (`radius < 0`): usa `|radius|` como radio exterior. El agujero interior es fijo en 0.2, suficiente para texto central.

  Debe llamarse dentro del render callback de `fn::run_app`.

code:
  #include "viz/pie_chart.h"
  #include "implot.h"

  void pie_chart(const char* title, const char* const* labels, const float* values, int count, float radius) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_Equal | ImPlotFlags_NoLegend)) {
          ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
          ImPlot::SetupAxesLimits(0, 1, 0, 1);
          if (radius < 0.0f) {
              ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, static_cast<double>(-radius), "%.1f", 90.0);
          } else {
              float r = (radius > 0.0f) ? radius : 0.4f;
              ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, static_cast<double>(r), "%.1f", 90.0);
          }
          ImPlot::EndPlot();
      }
  }

  void pie_chart(const char* title, const char* const* labels, const double* values, int count, double radius) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_Equal | ImPlotFlags_NoLegend)) {
          ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
          ImPlot::SetupAxesLimits(0, 1, 0, 1);
          if (radius < 0.0) {
              ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, -radius, "%.1f", 90.0);
          } else {
              double r = (radius > 0.0) ? radius : 0.4;
              ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, r, "%.1f", 90.0);
          }
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico"},{"name":"labels","desc":"Array de etiquetas para cada segmento del pie"},{"name":"values","desc":"Array de valores numericos para cada segmento"},{"name":"count","desc":"Numero de segmentos (longitud de labels y values)"},{"name":"radius","desc":"Radio del pie (0 = auto 0.4). Positivo = radio explicito. Negativo = modo donut con outer radius = |radius| e inner = 0.2"}],"output":"Renderiza el grafico circular en el frame ImGui actual"}


id: scatter_plot_cpp_viz
name: scatter_plot
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: void scatter_plot(const char* title, const float* xs, const float* ys, int count)
description: Renderiza un grafico de dispersion usando ImPlot dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","scatter"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/scatter_plot.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 0c6a73764dfdadd8538941a01048d05994e85d8484f488fff8c6d42c477a0842
documentation:
  # scatter_plot

  Wrapper atomico sobre `ImPlot::PlotScatter`. Renderiza un grafico de dispersion 2D.

  Debe llamarse dentro del render callback de `fn::run_app`.

code:
  #include "viz/scatter_plot.h"
  #include "implot.h"

  void scatter_plot(const char* title, const float* xs, const float* ys, int count) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImPlot::PlotScatter("##data", xs, ys, count);
          ImPlot::EndPlot();
      }
  }

  void scatter_plot(const char* title, const double* xs, const double* ys, int count) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImPlot::PlotScatter("##data", xs, ys, count);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico scatter"},{"name":"xs","desc":"Array de coordenadas X"},{"name":"ys","desc":"Array de coordenadas Y"},{"name":"count","desc":"Numero de puntos en los arrays xs/ys"}],"output":"Renderiza el grafico de dispersion en el frame ImGui actual"}


