# 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-05T16:40:39Z
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-05T16:40:39Z
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: 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: 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: 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: 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: 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"}


id: chart_colors_ts_core
name: chart_colors
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
signature: getChartColor(index: number): string
tags: ["chart","color","theme","palette","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/chart_colors.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 4a553948739ccbb4fcfd85df8ea279fec17f6ad52268e55152d298031b3d05ac
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/chart-base.tsx
description:
  Paleta de colores para gráficos basada en CSS variables del tema activo. Colores accesibles por índice cíclico.

example:
  ```typescript
  getChartColor(0) // 'hsl(var(--chart-1, 220 70% 50%))'
  getChartColor(7) // 'hsl(var(--chart-3, 30 80% 55%))' — cicla sobre 5 colores
  ```

notes:
  Usa CSS variables del tema con fallback hardcodeado. Los colores cambian automáticamente con el tema activo. También exporta `chartColors` (array) para uso directo.

code:
  export const chartColors = [
    '#3b82f6',
    '#10b981',
    '#f59e0b',
    '#8b5cf6',
    '#ef4444',
  ]

  export function getChartColor(index: number): string {
    return chartColors[index % chartColors.length]!
  }

params_schema:
  {"params":[{"name":"index","desc":"Índice cíclico en la paleta de colores (ciclará sobre 5 colores disponibles)"}],"output":"String con variable CSS de color para gráficos (ej: 'hsl(var(--chart-1, 220 70% 50%))')"}


id: format_compact_ts_core
name: format_compact
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
signature: formatCompact(n: number, decimals?: number): string
tags: ["format","number","compact","utility","display"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/format_compact.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Todas son funciones puras sin dependencias. Útiles en dashboards, KPI cards, tablas y tooltips.
content_hash: c1361e21edf78c98e02ed640215066fe93d3432e575d44fc4eeeca4308b4656b
description:
  Familia de funciones de formato compacto: números (K/M/B), frecuencia (Hz/KHz/MHz), bytes (KB/MB/GB), duración (ms/s/min/h).

example:
  ```typescript
  formatCompact(1234)       // '1.2K'
  formatCompact(1500000)    // '1.5M'
  formatHz(44100)           // '44.1 KHz'
  formatBytes(1073741824)   // '1.0 GB'
  formatDuration(3500)      // '3.5s'
  formatDuration(0.5)       // '500µs'
  ```

code:
  /**
   * Formatea un número en formato compacto (1K, 1.2M, etc.)
   * Soporta sufijos personalizados.
   */
  export function formatCompact(n: number, decimals: number = 1): string {
    if (Math.abs(n) >= 1_000_000_000) return (n / 1_000_000_000).toFixed(decimals) + 'B'
    if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(decimals) + 'M'
    if (Math.abs(n) >= 1_000) return (n / 1_000).toFixed(decimals) + 'K'
    return n.toString()
  }

  /**
   * Formatea frecuencia en Hz/KHz/MHz/GHz.
   */
  export function formatHz(hz: number, decimals: number = 1): string {
    if (hz >= 1_000_000_000) return (hz / 1_000_000_000).toFixed(decimals) + ' GHz'
    if (hz >= 1_000_000) return (hz / 1_000_000).toFixed(decimals) + ' MHz'
    if (hz >= 1_000) return (hz / 1_000).toFixed(decimals) + ' KHz'
    return hz + ' Hz'
  }

  /**
   * Formatea bytes en KB/MB/GB/TB.
   */
  export function formatBytes(bytes: number, decimals: number = 1): string {
    if (bytes >= 1_099_511_627_776) return (bytes / 1_099_511_627_776).toFixed(decimals) + ' TB'
    if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(decimals) + ' GB'
    if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(decimals) + ' MB'
    if (bytes >= 1_024) return (bytes / 1_024).toFixed(decimals) + ' KB'
    return bytes + ' B'
  }

  /**
   * Formatea duración en ms/s/min/h.
   */
  export function formatDuration(ms: number): string {
    if (ms >= 3_600_000) return (ms / 3_600_000).toFixed(1) + 'h'
    if (ms >= 60_000) return (ms / 60_000).toFixed(1) + 'min'
    if (ms >= 1_000) return (ms / 1_000).toFixed(1) + 's'
    if (ms >= 1) return ms.toFixed(0) + 'ms'
    return (ms * 1000).toFixed(0) + 'µs'
  }

params_schema:
  {"params":[{"name":"n","desc":"Número a formatear de forma compacta"},{"name":"decimals","desc":"Cantidad de decimales a mostrar (por defecto 1)"}],"output":"String con número formateado en notación compacta (ej: '1.2K', '1.5M', '1.0G')"}


id: get_computed_color_ts_core
name: get_computed_color
kind: function
lang: ts
domain: core
version: 1.0.0
purity: impure
signature: getComputedColor(cssVar: string): string
tags: ["theme","css","color","runtime","dom","canvas"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: frontend/functions/core/get_computed_color.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 1ff249841ef1967ab44fd952e402c3bb0f6160fa776a20b48d0ce1d44b349b4b
description:
  Resuelve una CSS variable de color a su valor RGB computado por el browser. Acepta '--primary', 'primary' o 'var(--primary)'. Util para canvas, sigma.js y APIs que no soportan CSS variables.

example:
  ```typescript
  import { getComputedColor } from './get_computed_color'

  // Todos estos formatos son equivalentes:
  const color1 = getComputedColor('--primary')      // "rgb(120, 80, 220)"
  const color2 = getComputedColor('primary')        // "rgb(120, 80, 220)"
  const color3 = getComputedColor('var(--primary)') // "rgb(120, 80, 220)"

  // Usar en canvas 2D
  const ctx = canvas.getContext('2d')
  ctx.fillStyle = getComputedColor('--background')
  ctx.strokeStyle = getComputedColor('--border')

  // Usar en sigma.js v2
  renderer.setSetting('defaultNodeColor', getComputedColor('--primary'))
  renderer.setSetting('defaultEdgeColor', getComputedColor('--muted'))
  ```

notes:
  Impura — muta el DOM temporalmente (append + remove) para forzar la resolucion del color. Solo disponible en browser.

  El browser retorna el valor en formato `rgb(r, g, b)` o `rgba(r, g, b, a)`, no en el formato original de la CSS variable (oklch, hsl, etc.). Si necesitas el valor original sin resolver, usar `getThemeTokens` en su lugar.

  Para multiples colores en un mismo render, llamar una vez y cachear los resultados — cada llamada hace un ciclo DOM.

code:
  /**
   * Resuelve una CSS variable de color a su valor RGB computado por el browser.
   *
   * Acepta cualquiera de los formatos:
   *   - `"--primary"`
   *   - `"primary"` (sin prefijo --)
   *   - `"var(--primary)"`
   *
   * Retorna el valor en formato `rgb(r, g, b)` o `rgba(r, g, b, a)` segun
   * como lo expanda el browser. Util para canvas, sigma.js y cualquier API
   * que no soporte CSS variables directamente.
   */
  export function getComputedColor(cssVar: string): string {
    const name = cssVar.startsWith('var(')
      ? cssVar.slice(4, -1).trim()
      : cssVar.startsWith('--')
        ? cssVar
        : `--${cssVar}`

    const el = document.createElement('div')
    el.style.color = `var(${name})`
    el.style.display = 'none'
    document.body.appendChild(el)
    const computed = getComputedStyle(el).color
    document.body.removeChild(el)
    return computed
  }

params_schema:
  {"params":[{"name":"cssVar","desc":"Variable CSS a resolver (acepta '--primary', 'primary' o 'var(--primary)')"}],"output":"String con valor RGB computado por el browser (ej: 'rgb(120, 80, 220)')"}


id: get_series_color_ts_core
name: get_series_color
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
signature: getSeriesColor(index: number, color?: string): string
tags: ["chart","color","series","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/get_series_color.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 051913368213e95b67a4abfb48fdd7ec624c19ddba9e86bc01846418311cf6ab
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/chart-base.tsx
description:
  Devuelve color para una serie de gráfico por índice cíclico, o el color explícito si se proporciona.

example:
  ```typescript
  getSeriesColor(0)            // '#3b82f6'
  getSeriesColor(5)            // '#3b82f6' (cicla sobre 5 colores)
  getSeriesColor(0, '#ff0000') // '#ff0000' (usa el explícito)
  ```

notes:
  Paleta fija de 5 colores: azul, verde, ámbar, violeta, rosa. También exporta `defaultColors` para uso directo.

code:
  const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']

  export function getSeriesColor(index: number, color?: string): string {
    return color ?? defaultColors[index % defaultColors.length]!
  }

  export { defaultColors }

params_schema:
  {"params":[{"name":"index","desc":"Índice de serie (ciclará sobre paleta de 5 colores)"},{"name":"color","desc":"Color explícito a usar si se proporciona (opcional)"}],"output":"String con color HEX o el color explícito proporcionado"}


id: wails_cache_ts_core
name: wails_cache
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
tags: ["wails","cache","ipc","reactive","state"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/wails_cache.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: fcdbc7db041c22eb5b51c3e6ae938454bd0fc18e98e1d8261b4b85b66442996f
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/cache.ts
signature:
  class WailsCache { get<T>(key: string[]): T | null; set<T>(key: string[], data: T): void; invalidate(key: string[]): void; subscribe(key: string[], cb: () => void): () => void }

description:
  Cache reactivo para IPC Wails con invalidación por prefijo, suscripción a cambios y tracking de staleness. Singleton global.

example:
  ```typescript
  import { wailsCache } from './wails_cache'

  wailsCache.set(['users', '123'], userData)
  const user = wailsCache.get<User>(['users', '123'])
  wailsCache.invalidate(['users']) // invalida users:*
  const unsub = wailsCache.subscribe(['users'], () => console.log('changed'))
  ```

notes:
  Key como string[] permite invalidación jerárquica: `invalidate(['users'])` invalida `users`, `users:123`, `users:456`, etc.

code:
  interface CacheEntry {
    data: unknown
    timestamp: Date
  }

  export class WailsCache {
    private cache = new Map<string, CacheEntry>()
    private subscribers = new Map<string, Set<() => void>>()

    /** Generar key string desde array */
    private getKey(queryKey: string[]): string {
      return queryKey.join(':')
    }

    /** Obtener dato del cache */
    get<T>(queryKey: string[]): T | null {
      const entry = this.cache.get(this.getKey(queryKey))
      return (entry?.data as T) ?? null
    }

    /** Guardar dato en cache */
    set<T>(queryKey: string[], data: T): void {
      const key = this.getKey(queryKey)
      this.cache.set(key, { data, timestamp: new Date() })
      this.notifySubscribers(key)
    }

    /** Verificar si existe en cache */
    has(queryKey: string[]): boolean {
      return this.cache.has(this.getKey(queryKey))
    }

    /** Obtener timestamp de última actualización */
    getTimestamp(queryKey: string[]): Date | null {
      const entry = this.cache.get(this.getKey(queryKey))
      return entry?.timestamp ?? null
    }

    /** Verificar si los datos están stale */
    isStale(queryKey: string[], staleTime: number): boolean {
      const entry = this.cache.get(this.getKey(queryKey))
      if (!entry) return true
      return Date.now() - entry.timestamp.getTime() > staleTime
    }

    /** Invalidar cache (esta key y todas las que empiezan igual) */
    invalidate(queryKey: string[]): void {
      const prefix = this.getKey(queryKey)
      const keysToDelete: string[] = []

      for (const key of this.cache.keys()) {
        if (key === prefix || key.startsWith(prefix + ':')) {
          keysToDelete.push(key)
        }
      }

      keysToDelete.forEach((key) => {
        this.cache.delete(key)
        this.notifySubscribers(key)
      })
    }

    /** Limpiar todo el cache */
    clear(): void {
      this.cache.clear()
      this.subscribers.forEach((_, key) => this.notifySubscribers(key))
    }

    /** Subscribirse a cambios en una key */
    subscribe(queryKey: string[], callback: () => void): () => void {
      const key = this.getKey(queryKey)
      if (!this.subscribers.has(key)) {
        this.subscribers.set(key, new Set())
      }
      this.subscribers.get(key)!.add(callback)

      return () => {
        this.subscribers.get(key)?.delete(callback)
      }
    }

    /** Notificar a subscribers */
    private notifySubscribers(key: string): void {
      this.subscribers.get(key)?.forEach((callback) => callback())
    }

    /** Obtener tamaño del cache */
    get size(): number {
      return this.cache.size
    }

    /** Obtener todas las keys */
    keys(): string[] {
      return Array.from(this.cache.keys())
    }
  }

  // Singleton global
  export const wailsCache = new WailsCache()

params_schema:
  {"output":"Clase WailsCache con métodos get, set, invalidate, subscribe para gestionar caché reactiva con invalidación jerárquica"}


id: accordion_ts_ui
name: accordion
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Accordion(props: AccordionProps): JSX.Element
tags: ["accordion","collapsible","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/accordion.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: []
content_hash: c7855b6b83792072a0da54136b86c658417fcd87c40c71e52e937647b3f40b6c
description:
  Secciones colapsables con animaciones. Mantine Accordion. Composable: AccordionItem + AccordionTrigger + AccordionContent.

example:
  ```tsx
  <Accordion defaultValue="section-1">
    <AccordionItem value="section-1">
      <AccordionTrigger>Seccion 1</AccordionTrigger>
      <AccordionContent>
        Contenido de la primera seccion.
      </AccordionContent>
    </AccordionItem>
    <AccordionItem value="section-2">
      <AccordionTrigger>Seccion 2</AccordionTrigger>
      <AccordionContent>
        Contenido de la segunda seccion.
      </AccordionContent>
    </AccordionItem>
  </Accordion>
  ```

props:
  [{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales para el contenedor"}]

notes:
  Usa Mantine Accordion nativo. Soporta type single (default) y multiple para multiples items abiertos. El chevron se maneja automaticamente por Mantine. AccordionItem requiere prop value unico. Exports: Accordion, AccordionItem, AccordionTrigger, AccordionContent.

code:
  import * as React from "react"
  import { Accordion as MantineAccordion } from "@mantine/core"

  interface AccordionItem {
    value: string
    trigger: React.ReactNode
    content: React.ReactNode
    disabled?: boolean
  }

  interface AccordionProps {
    items?: AccordionItem[]
    type?: "single" | "multiple"
    defaultValue?: string | string[]
    className?: string
    itemClassName?: string
    children?: React.ReactNode
  }

  function Accordion({ className, type, defaultValue, children }: AccordionProps) {
    if (type === "multiple") {
      return (
        <MantineAccordion
          multiple
          data-slot="accordion"
          className={className}
          defaultValue={Array.isArray(defaultValue) ? defaultValue : undefined}
        >
          {children}
        </MantineAccordion>
      )
    }

    return (
      <MantineAccordion
        data-slot="accordion"
        className={className}
        defaultValue={typeof defaultValue === "string" ? defaultValue : undefined}
      >
        {children}
      </MantineAccordion>
    )
  }

  interface AccordionItemProps {
    value: string
    className?: string
    children?: React.ReactNode
    disabled?: boolean
  }

  function AccordionItem({ className, value, children, ...props }: AccordionItemProps) {
    return (
      <MantineAccordion.Item
        data-slot="accordion-item"
        value={value}
        className={className}
        {...props}
      >
        {children}
      </MantineAccordion.Item>
    )
  }

  function AccordionTrigger({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
    return (
      <MantineAccordion.Control
        data-slot="accordion-trigger"
        className={className}
        {...props}
      >
        {children}
      </MantineAccordion.Control>
    )
  }

  function AccordionContent({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
    return (
      <MantineAccordion.Panel
        data-slot="accordion-content"
        className={className}
        {...props}
      >
        {children}
      </MantineAccordion.Panel>
    )
  }

  export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
  export type { AccordionItem as AccordionItemData, AccordionProps }

params_schema:
  {"output":"Componente Accordion que renderiza secciones colapsables con soporte para múltiples items abiertos simultáneamente"}


id: action_icon_ts_ui
name: action_icon
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnActionIcon(props: FnActionIconProps): JSX.Element
description: Boton de icono con variantes, loading y tooltip opcional. Wrapper sobre Mantine ActionIcon.
tags: ["mantine","button","icon","action","tooltip","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/action_icon.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["filled","light","outline","transparent","default","subtle"]
content_hash: 36e5b0e140513f01a20889f2f6ad5155549b3423fc1e650a8272967f820d3d9e
params_schema: {"output":"Boton de icono con tooltip opcional, estados loading/disabled y multiples variantes"}
example:
  ```tsx
  import { FnActionIcon } from '@fn_library'
  import { IconSettings } from '@tabler/icons-react'

  <FnActionIcon
    icon={<IconSettings size={18} />}
    tooltip="Configuracion"
    variant="light"
    onClick={() => openSettings()}
  />
  ```

props:
  [{"name":"icon","type":"ReactNode","required":true,"description":"Icono a renderizar dentro del boton"},{"name":"variant","type":"'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'","required":false,"description":"Variante visual del boton, default 'default'"},{"name":"size","type":"MantineSize | number","required":false,"description":"Tamano del boton"},{"name":"color","type":"MantineColor","required":false,"description":"Color del boton"},{"name":"onClick","type":"MouseEventHandler","required":false,"description":"Callback al hacer click"},{"name":"loading","type":"boolean","required":false,"description":"Muestra spinner de carga"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el boton"},{"name":"tooltip","type":"string","required":false,"description":"Si se provee, envuelve el boton en un Tooltip"}]

notes:
  Wrapper sobre Mantine `ActionIcon`. Si se provee `tooltip`, envuelve automaticamente en Mantine `Tooltip`. Compatible con iconos de `@tabler/icons-react`.

code:
  import * as React from 'react'
  import { ActionIcon, Tooltip } from '@mantine/core'
  import type { MantineSize, MantineColor } from '@mantine/core'

  interface FnActionIconProps {
    icon: React.ReactNode
    variant?: 'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'
    size?: MantineSize | number
    color?: MantineColor
    onClick?: React.MouseEventHandler<HTMLButtonElement>
    loading?: boolean
    disabled?: boolean
    tooltip?: string
  }

  function FnActionIcon({
    icon,
    variant = 'default',
    size = 'md',
    color,
    onClick,
    loading,
    disabled,
    tooltip,
  }: FnActionIconProps) {
    const button = (
      <ActionIcon
        variant={variant}
        size={size}
        color={color}
        onClick={onClick}
        loading={loading}
        disabled={disabled}
      >
        {icon}
      </ActionIcon>
    )

    if (tooltip) {
      return <Tooltip label={tooltip}>{button}</Tooltip>
    }

    return button
  }

  export { FnActionIcon }
  export type { FnActionIconProps }


id: alert_ts_ui
name: alert
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element
tags: ["alert","feedback","component","ui","notification","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/alert.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["default","destructive"]
content_hash: 42f8563e2dfa14e41cf1f0d9c0547ebd0ea8e47ee4d1d026c1e1a57e1849a464
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/alert.tsx
description:
  Alerta accesible con variantes default y destructive. Mantine Alert con slots para título, descripción y acción.

example:
  ```tsx
  <Alert variant="destructive">
    <AlertCircle />
    <AlertTitle>Error</AlertTitle>
    <AlertDescription>Something went wrong.</AlertDescription>
  </Alert>
  ```

props:
  [{"name":"variant","type":"'default' | 'destructive'","required":false,"description":"Variante visual"}]

notes:
  Exporta 4 subcomponentes composables via data-slot: Alert, AlertTitle, AlertDescription, AlertAction.
  AlertAction se posiciona absolute top-right para acciones secundarias (ej: boton cerrar).
  alertVariants se exporta como objeto vacio por compatibilidad (Mantine gestiona variantes via color prop).

code:
  import * as React from 'react'
  import { Alert as MantineAlert, Box, Text } from '@mantine/core'

  type AlertVariant = 'default' | 'destructive'

  const variantColorMap: Record<AlertVariant, string | undefined> = {
    default: undefined,
    destructive: 'red',
  }

  function Alert({
    className,
    variant = 'default',
    children,
    ...props
  }: React.ComponentProps<'div'> & { variant?: AlertVariant }) {
    return (
      <MantineAlert
        data-slot="alert"
        color={variantColorMap[variant]}
        radius="md"
        variant="light"
        className={className}
        {...props}
      >
        {children}
      </MantineAlert>
    )
  }

  function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="alert-title"
        fw={500}
        size="sm"
        className={className}
        {...props}
      />
    )
  }

  function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="alert-description"
        size="sm"
        c="dimmed"
        className={className}
        {...props}
      />
    )
  }

  function AlertAction({ className, style, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="alert-action"
        style={{ position: 'absolute', top: 'var(--mantine-spacing-xs)', right: 'var(--mantine-spacing-xs)', ...style }}
        className={className}
        {...props}
      />
    )
  }

  const alertVariants = {} as const

  export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants }

params_schema:
  {"output":"Componente Alert que renderiza una alerta accesible via Mantine Alert con slots para título, descripción y acción"}


id: analytics_page_ts_ui
name: analytics_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: analyticsPage(props: AnalyticsPageProps): ReactElement
tags: ["analytics","dashboard","kpi","charts","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/analytics_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: db4d028b6614888ae01a720ba3e9f313e9cd1c49c3dcb4dd09d37cbae2b1892f
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera un dashboard de analytics completo con header, fila de KPIs con deltas y grid de charts configurables.

example:
  ```tsx
  analyticsPage({
    title: 'Sales Analytics',
    metrics: [
      { label: 'Revenue', value: '$124,500', delta: { value: 12.5, isPositive: true } },
      { label: 'Orders', value: '1,234', delta: { value: -3.2, isPositive: false } },
      { label: 'Avg Order', value: '$101', delta: { value: 0, isPositive: true } },
      { label: 'Customers', value: '892' },
    ],
    charts: [
      { id: 'revenue', title: 'Revenue Over Time', type: 'area', span: 2, content: <AreaChart data={revenueData} xKey="month" yKey="revenue" /> },
      { id: 'orders', title: 'Orders by Category', type: 'bar', content: <BarChart data={orderData} xKey="category" yKey="count" /> },
      { id: 'trends', title: 'Customer Trends', type: 'line', content: <LineChart data={trendData} xKey="week" yKey="customers" /> },
    ],
  })
  ```

notes:
  Layout inteligente: los KPIs se ajustan automáticamente a 2/3/4 columnas según cantidad. Los charts soportan span para ancho completo.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, Paper, SimpleGrid } from '@mantine/core'

  interface MetricConfig {
    label: string
    value: string | number
    delta?: { value: number; isPositive: boolean }
    sparklineData?: number[]
  }

  interface ChartConfig {
    id: string
    title: string
    type: 'line' | 'bar' | 'area'
    span?: 1 | 2
    height?: number
    content: React.ReactNode
  }

  interface AnalyticsPageProps {
    title: string
    subtitle?: string
    dateRange?: React.ReactNode
    metrics: MetricConfig[]
    charts: ChartConfig[]
    actions?: React.ReactNode
    className?: string
  }

  export function analyticsPage({
    title,
    subtitle,
    dateRange,
    metrics,
    charts,
    actions,
  }: AnalyticsPageProps): React.ReactElement {
    const metricCols = metrics.length <= 2 ? { base: 1, md: 2 } : metrics.length <= 3 ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 4 }

    return (
      <Stack gap="lg">
        {/* Header */}
        <Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Stack gap={4}>
            <Title order={2}>{title}</Title>
            {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
          </Stack>
          <Group gap="xs">
            {dateRange}
            {actions}
          </Group>
        </Group>

        {/* KPI Row */}
        <SimpleGrid cols={metricCols} spacing="md">
          {metrics.map((metric, i) => (
            <Paper key={i} p="md" withBorder shadow="xs">
              <Text size="sm" c="dimmed">{metric.label}</Text>
              <Group mt="xs" justify="space-between" align="flex-end" gap="md">
                <Stack gap={4}>
                  <Text fz={30} fw={700} lh={1}>{metric.value}</Text>
                  {metric.delta && (
                    <Text
                      size="sm"
                      fw={500}
                      c={metric.delta.value === 0 ? 'dimmed' : metric.delta.isPositive ? 'green' : 'red'}
                    >
                      {metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%
                    </Text>
                  )}
                </Stack>
              </Group>
            </Paper>
          ))}
        </SimpleGrid>

        {/* Charts Grid */}
        <SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
          {charts.map((chart) => (
            <Paper
              key={chart.id}
              p="md"
              withBorder
              shadow="xs"
              radius="md"
              style={chart.span === 2 ? { gridColumn: 'span 2' } : undefined}
            >
              <Text size="sm" fw={500} c="dimmed" mb="sm">{chart.title}</Text>
              {chart.content}
            </Paper>
          ))}
        </SimpleGrid>
      </Stack>
    )
  }

  export type { AnalyticsPageProps, MetricConfig, ChartConfig }

params_schema:
  {"params":[{"name":"props","desc":"Configuración del dashboard: título, métricas con deltas, y lista de charts con span"}],"output":"Componente ReactElement que renderiza un dashboard de analytics completo con header, KPIs y grid de charts"}


id: app_shell_ts_ui
name: app_shell
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnAppShell(props: FnAppShellProps): JSX.Element
description: Layout shell con header, navbar colapsable y area principal. Wrapper sobre Mantine AppShell.
tags: ["mantine","layout","shell","navigation","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/app_shell.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: cfece7820c7408a5cab19f4bc447b9b77f9ebc02b222e41eafd0d220ec5bc2cc
params_schema: {"output":"Layout de aplicacion con header fijo, sidebar colapsable y area de contenido principal"}
example:
  ```tsx
  import { FnAppShell } from '@fn_library'

  <FnAppShell
    header={<Group px="md">Logo</Group>}
    navbar={<NavLinks />}
    navbarCollapsed={collapsed}
  >
    <MainContent />
  </FnAppShell>
  ```

props:
  [{"name":"header","type":"ReactNode","required":false,"description":"Contenido del header superior"},{"name":"navbar","type":"ReactNode","required":false,"description":"Contenido del sidebar de navegacion"},{"name":"navbarWidth","type":"number","required":false,"description":"Ancho del navbar en px, default 250"},{"name":"navbarCollapsed","type":"boolean","required":false,"description":"Si el navbar esta colapsado"},{"name":"children","type":"ReactNode","required":true,"description":"Contenido principal del area main"}]

notes:
  Wrapper sobre Mantine `AppShell`. El header tiene altura fija de 60px. El navbar colapsa tanto en mobile como en desktop cuando `navbarCollapsed` es true. El breakpoint de responsive es `sm`.

code:
  import * as React from 'react'
  import { AppShell } from '@mantine/core'

  interface FnAppShellProps {
    header?: React.ReactNode
    navbar?: React.ReactNode
    navbarWidth?: number
    navbarCollapsed?: boolean
    children: React.ReactNode
  }

  function FnAppShell({
    header,
    navbar,
    navbarWidth = 250,
    navbarCollapsed = false,
    children,
  }: FnAppShellProps) {
    return (
      <AppShell
        header={header ? { height: 60 } : undefined}
        navbar={navbar ? { width: navbarWidth, breakpoint: 'sm', collapsed: { mobile: navbarCollapsed, desktop: navbarCollapsed } } : undefined}
        padding="md"
      >
        {header && <AppShell.Header>{header}</AppShell.Header>}
        {navbar && <AppShell.Navbar p="md">{navbar}</AppShell.Navbar>}
        <AppShell.Main>{children}</AppShell.Main>
      </AppShell>
    )
  }

  export { FnAppShell }
  export type { FnAppShellProps }


id: area_chart_ts_ui
name: area_chart
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: AreaChart(props: AreaChartProps): JSX.Element
description: Gráfico de área @mantine/charts con gradientes automáticos, multi-series, stacking y tooltips.
tags: ["chart","area","visualization","mantine","gradient","component","ui"]
uses_functions: ["chart_container_ts_ui"]
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/charts","@mantine/core"]
tests: []
file_path: frontend/functions/ui/area_chart.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default","stacked"]
content_hash: 38fd018a587009fb4d1b4e11897d43f0440076631d5252d36fd9b4c763d0f0ea
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/area-chart.tsx
example:
  ```tsx
  <AreaChart data={data} xKey="date" yKey="revenue" gradient />
  <AreaChart data={data} xKey="date" series={series} stacked showLegend />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Array de datos"},{"name":"xKey","type":"string","required":true,"description":"Key del eje X"},{"name":"stacked","type":"boolean","required":false,"description":"Apilar áreas"},{"name":"gradient","type":"GradientConfig | boolean","required":false,"description":"Gradiente (true por defecto)"},{"name":"series","type":"Series[]","required":false,"description":"Series de datos para multi-series"}]

code:
  import { AreaChart as MantineAreaChart } from '@mantine/charts'
  import { Paper } from '@mantine/core'
  import { type Series, getSeriesColor } from './chart_container'

  interface AreaChartProps {
    data: Record<string, unknown>[]
    xKey: string
    yKey?: string
    series?: Series[]
    stacked?: boolean
    gradient?: boolean
    showGrid?: boolean
    showLegend?: boolean
    height?: number
    xAxisFormatter?: (value: unknown) => string
    yAxisFormatter?: (value: unknown) => string
    valueFormatter?: (value: number) => string
  }

  function AreaChartComponent({
    data, xKey, yKey, series, stacked = false, gradient = true, showGrid = true,
    showLegend = false, height = 300, xAxisFormatter, yAxisFormatter,
    valueFormatter = (v) => v.toLocaleString(),
  }: AreaChartProps) {
    const chartSeries = series
      ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
      : yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []

    return (
      <Paper p="md">
        <MantineAreaChart
          h={height}
          data={data}
          dataKey={xKey}
          series={chartSeries}
          type={stacked ? 'stacked' : 'default'}
          curveType="monotone"
          withGradient={gradient}
          gridAxis={showGrid ? 'xy' : 'none'}
          withLegend={showLegend}
          withTooltip
          valueFormatter={valueFormatter}
          xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
          yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
        />
      </Paper>
    )
  }

  /** @deprecated Gradient is handled by Mantine's withGradient prop */
  type GradientConfig = { from: string; to: string }

  export const AreaChart = AreaChartComponent
  export type { AreaChartProps, GradientConfig }

params_schema:
  {"output":"Componente JSX que renderiza un gráfico de área con gradientes, multi-series y tooltips"}


id: auth_form_ts_ui
name: auth_form
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: AuthForm(config: AuthFormConfig): ReactElement
tags: ["auth","login","register","form","page","ui","mantine","toggle"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@mantine/hooks"]
tests: []
file_path: frontend/functions/ui/auth_form.tsx
created_at: 2026-04-06T10:43:09Z
updated_at: 2026-04-06T10:43:09Z
props: []
emits: ["onSubmit"]
framework: react
variant: []
content_hash: 5ef739865a7d0cb0c9ed2724304b37af32d0ef80675e57521f44806142ef7d90
description:
  Genera página de autenticación con toggle login/register, social buttons opcionales, campos extra en registro y validación. Basado en Mantine AuthenticationForm.

example:
  ### Config mínima (solo login)

  ```tsx
  import { AuthForm } from '@fn_library/auth_form'

  function LoginPage() {
    return (
      <AuthForm
        title="Acceder"
        onSubmit={({ type, email, password }) => {
          console.log(type, email, password)
        }}
      />
    )
  }
  ```

  ### Con social buttons y campos extra en registro

  ```tsx
  import { AuthForm } from '@fn_library/auth_form'
  import { IconBrandGoogle, IconBrandGithub } from '@tabler/icons-react'

  function AuthPage() {
    return (
      <AuthForm
        title="fn_registry"
        defaultType="register"
        socialButtons={[
          { label: 'Google', icon: <IconBrandGoogle size={16} />, onClick: () => signInWithGoogle() },
          { label: 'GitHub', icon: <IconBrandGithub size={16} />, onClick: () => signInWithGitHub() },
        ]}
        extraFields={[
          { name: 'name', label: 'Nombre completo', placeholder: 'Lucas García', required: true },
          { name: 'company', label: 'Empresa', placeholder: 'Acme Corp' },
        ]}
        onSubmit={({ type, email, password, name, company }) => {
          if (type === 'register') {
            registerUser({ email, password, name, company })
          } else {
            loginUser({ email, password })
          }
        }}
      />
    )
  }
  ```

notes:
  Función con estado interno (useToggle, useState de @mantine/hooks). Gestiona el toggle entre login y register sin prop externa — el padre solo recibe el valor final via onSubmit.type.

  Los `extraFields` solo se renderizan en modo register. El campo de términos y condiciones (Checkbox) también es exclusivo del registro.

  Los `socialButtons` se renderizan con un Divider "O continúa con email" cuando están presentes. Sin socialButtons el Divider no aparece.

  El campo `password` usa PasswordInput de Mantine, que incluye el toggle de visibilidad integrado.

code:
  import * as React from 'react'
  import {
    Anchor,
    Button,
    Checkbox,
    Divider,
    Group,
    Paper,
    PasswordInput,
    Stack,
    Text,
    TextInput,
    Title,
    Container,
    type PaperProps,
  } from '@mantine/core'
  import { useToggle, upperFirst } from '@mantine/hooks'

  interface SocialButtonConfig {
    label: string
    icon?: React.ReactNode
    onClick?: () => void
  }

  interface ExtraFieldConfig {
    name: string
    label: string
    placeholder?: string
    required?: boolean
  }

  interface AuthFormSubmitValues {
    type: 'login' | 'register'
    email: string
    password: string
    [key: string]: unknown
  }

  interface AuthFormConfig {
    /** Título principal de la página */
    title?: string
    /** Botones de autenticación social opcionales */
    socialButtons?: SocialButtonConfig[]
    /** Campos adicionales que se muestran solo en el modo registro */
    extraFields?: ExtraFieldConfig[]
    /** Callback invocado al enviar el formulario */
    onSubmit?: (values: AuthFormSubmitValues) => void
    /** Modo inicial: 'login' (default) o 'register' */
    defaultType?: 'login' | 'register'
    /** Props adicionales para el Paper contenedor */
    paperProps?: PaperProps
  }

  function AuthForm({
    title = 'Welcome',
    socialButtons = [],
    extraFields = [],
    onSubmit,
    defaultType = 'login',
    paperProps,
  }: AuthFormConfig): React.ReactElement {
    const [type, toggle] = useToggle<'login' | 'register'>([
      defaultType,
      defaultType === 'login' ? 'register' : 'login',
    ])
    const [email, setEmail] = React.useState('')
    const [password, setPassword] = React.useState('')
    const [terms, setTerms] = React.useState(true)
    const [extraValues, setExtraValues] = React.useState<Record<string, string>>({})

    const handleSubmit = (e: React.FormEvent) => {
      e.preventDefault()
      onSubmit?.({ type, email, password, ...extraValues })
    }

    const handleExtraChange = (name: string, value: string) => {
      setExtraValues((prev) => ({ ...prev, [name]: value }))
    }

    return (
      <Container size={420} py={40}>
        <Paper radius="md" p="lg" withBorder {...paperProps}>
          <Title order={2} ta="center" mb="md">
            {title}
          </Title>

          {socialButtons.length > 0 && (
            <>
              <Group grow mb="md" gap="xs">
                {socialButtons.map((btn) => (
                  <Button
                    key={btn.label}
                    variant="default"
                    radius="xl"
                    leftSection={btn.icon}
                    onClick={btn.onClick}
                  >
                    {btn.label}
                  </Button>
                ))}
              </Group>
              <Divider label="O continúa con email" labelPosition="center" my="lg" />
            </>
          )}

          <form onSubmit={handleSubmit}>
            <Stack gap="sm">
              {type === 'register' &&
                extraFields.map((field) => (
                  <TextInput
                    key={field.name}
                    label={field.label}
                    placeholder={field.placeholder}
                    required={field.required}
                    value={extraValues[field.name] ?? ''}
                    onChange={(e) => handleExtraChange(field.name, e.currentTarget.value)}
                    radius="md"
                  />
                ))}

              <TextInput
                required
                label="Email"
                placeholder="tu@email.com"
                value={email}
                onChange={(e) => setEmail(e.currentTarget.value)}
                radius="md"
              />

              <PasswordInput
                required
                label="Contraseña"
                placeholder="Tu contraseña"
                value={password}
                onChange={(e) => setPassword(e.currentTarget.value)}
                radius="md"
              />

              {type === 'register' && (
                <Checkbox
                  label="Acepto los términos y condiciones"
                  checked={terms}
                  onChange={(e) => setTerms(e.currentTarget.checked)}
                />
              )}
            </Stack>

            <Group justify="space-between" mt="xl">
              <Anchor
                component="button"
                type="button"
                c="dimmed"
                size="xs"
                onClick={() => toggle()}
              >
                {type === 'register'
                  ? '¿Ya tienes cuenta? Inicia sesión'
                  : '¿No tienes cuenta? Regístrate'}
              </Anchor>
              <Button type="submit" radius="xl">
                {upperFirst(type)}
              </Button>
            </Group>
          </form>

          {type === 'register' && (
            <Text c="dimmed" size="xs" ta="center" mt="md">
              Al registrarte aceptas nuestra{' '}
              <Anchor size="xs" href="#">
                política de privacidad
              </Anchor>
              .
            </Text>
          )}
        </Paper>
      </Container>
    )
  }

  export { AuthForm }
  export type { AuthFormConfig, AuthFormSubmitValues, SocialButtonConfig, ExtraFieldConfig }

params_schema:
  {"params":[{"name":"title","desc":"Título principal que aparece en la cabecera del formulario (default: 'Welcome')"},{"name":"socialButtons","desc":"Lista de botones de login social, cada uno con label, icono opcional y callback onClick"},{"name":"extraFields","desc":"Campos de texto adicionales que se muestran únicamente en el modo registro (ej: nombre, empresa)"},{"name":"onSubmit","desc":"Callback invocado al enviar el formulario con type ('login'|'register'), email, password y valores de extraFields"},{"name":"defaultType","desc":"Modo inicial del formulario: 'login' (default) o 'register'"},{"name":"paperProps","desc":"Props de Mantine Paper para personalizar el contenedor (shadow, radius, p, etc.)"}],"output":"Página de autenticación completa con toggle login/register, campos email/password, botones sociales opcionales y campo de términos en registro"}


id: autocomplete_ts_ui
name: autocomplete
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Autocomplete(props: AutocompleteProps): JSX.Element
tags: ["autocomplete","input","form","suggestions","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/autocomplete.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onChange"]
framework: react
variant: []
content_hash: 73fb05057ed560fae5d6aeae75ca918118858b2570c136893dae524cd28d8ce0
params_schema: {"output":"Componente Autocomplete que renderiza input con dropdown de sugerencias filtradas"}
description:
  Input con sugerencias de autocompletado. Permite valores libres a diferencia de Select. Wrapper sobre Mantine Autocomplete.

example:
  ```tsx
  import { Autocomplete } from '@fn_library/autocomplete'

  // Básico — lista de strings
  function BasicAutocomplete() {
    return (
      <Autocomplete
        label="País"
        placeholder="Escribe para buscar..."
        data={['Argentina', 'Brasil', 'Chile', 'Colombia', 'Uruguay']}
      />
    )
  }

  // Con grupos
  function GroupedAutocomplete() {
    return (
      <Autocomplete
        label="Ciudad"
        placeholder="Selecciona una ciudad"
        data={[
          { value: 'Buenos Aires', group: 'Argentina' },
          { value: 'Rosario', group: 'Argentina' },
          { value: 'São Paulo', group: 'Brasil' },
          { value: 'Río de Janeiro', group: 'Brasil' },
        ]}
        limit={5}
      />
    )
  }

  // Con loading y clearable (búsqueda asíncrona)
  function AsyncAutocomplete() {
    const [value, setValue] = useState('')
    const [data, setData] = useState<string[]>([])
    const [loading, setLoading] = useState(false)

    const handleChange = async (val: string) => {
      setValue(val)
      if (val.length < 2) return
      setLoading(true)
      const results = await fetchSuggestions(val)
      setData(results)
      setLoading(false)
    }

    return (
      <Autocomplete
        label="Búsqueda"
        placeholder="Escribe al menos 2 caracteres..."
        value={value}
        onChange={handleChange}
        data={data}
        loading={loading}
        clearable
      />
    )
  }
  ```

props:
  [{"name":"data","type":"string[] | { value: string; label?: string; group?: string }[]","required":true,"description":"Lista de opciones a mostrar en el dropdown"},{"name":"value","type":"string","required":false,"description":"Valor controlado del input"},{"name":"onChange","type":"(value: string) =\u003e void","required":false,"description":"Callback al cambiar el valor del input"},{"name":"label","type":"string","required":false,"description":"Etiqueta visible encima del input"},{"name":"placeholder","type":"string","required":false,"description":"Texto placeholder cuando el input está vacío"},{"name":"clearable","type":"boolean","required":false,"description":"Muestra botón para limpiar el valor"},{"name":"loading","type":"boolean","required":false,"description":"Muestra spinner de carga en el input"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input"},{"name":"limit","type":"number","required":false,"description":"Número máximo de sugerencias a mostrar en el dropdown"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño visual del input"}]

notes:
  A diferencia de `Select`, `Autocomplete` permite que el usuario ingrese cualquier valor libre, no solo los de la lista. Ideal para búsquedas con sugerencias donde el valor final puede no estar en el dataset.

  Cuando `data` contiene objetos con `group`, el dropdown agrupa visualmente las opciones bajo el encabezado del grupo.

  El prop `limit` controla cuántas sugerencias se muestran simultáneamente (por defecto Mantine muestra todas). Útil para datasets grandes o búsquedas asíncronas donde se quiere limitar el ruido visual.

code:
  import { Autocomplete as MantineAutocomplete, type AutocompleteProps as MantineAutcompleteProps } from '@mantine/core'

  interface AutocompleteProps extends MantineAutcompleteProps {}

  function Autocomplete(props: AutocompleteProps) {
    return <MantineAutocomplete {...props} />
  }

  export { Autocomplete }
  export type { AutocompleteProps }


id: avatar_ts_ui
name: avatar
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Avatar(props: AvatarProps): JSX.Element
tags: ["avatar","user","image","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/avatar.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["xs","sm","md","lg","xl"]
content_hash: ddb73af7cb073f1bc07d2626962299edd45ecaa4159d6c1cd29364f2737832cf
description:
  Imagen de usuario circular con fallback a iniciales generadas automaticamente. 5 tamaños via Mantine Avatar.

example:
  ```tsx
  // Con imagen
  <Avatar src="https://example.com/user.jpg" alt="Juan Perez" size="md" />

  // Con fallback a iniciales
  <Avatar fallback="Juan Perez" size="lg" />

  // Iniciales explicitas
  <Avatar initials="JD" size="sm" />

  // Maneja error de imagen automaticamente
  <Avatar src="/broken-url.jpg" fallback="Maria Garcia" />
  ```

props:
  [{"name":"src","type":"string","required":false,"description":"URL de la imagen"},{"name":"alt","type":"string","required":false,"description":"Texto alternativo de la imagen"},{"name":"fallback","type":"string","required":false,"description":"Nombre completo del que extraer iniciales (ej: 'Juan Perez' -\u003e 'JP')"},{"name":"initials","type":"string","required":false,"description":"Iniciales explicitas para el fallback (sobrescribe fallback)"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamanio del avatar (default: md)"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Usa Mantine Avatar que maneja errores de carga de imagen nativamente. La funcion getInitials extrae 2 iniciales del nombre completo (primera y ultima palabra). Si solo hay una palabra, toma los 2 primeros caracteres. Usa forwardRef para compatibilidad con wrappers.

code:
  import * as React from 'react'
  import { Avatar as MantineAvatar } from '@mantine/core'

  type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'

  const sizeMap: Record<AvatarSize, string> = {
    xs: 'sm',
    sm: 'sm',
    md: 'md',
    lg: 'lg',
    xl: 'xl',
  }

  interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> {
    src?: string
    alt?: string
    fallback?: string
    initials?: string
    size?: AvatarSize
  }

  function getInitials(name?: string): string {
    if (!name) return '?'
    const parts = name.trim().split(/\s+/)
    const first = parts[0] ?? ''
    const last = parts[parts.length - 1] ?? ''
    if (parts.length === 1) return first.slice(0, 2).toUpperCase()
    return ((first[0] ?? '') + (last[0] ?? '')).toUpperCase()
  }

  /** Kept for backwards compatibility */
  const avatarVariants = sizeMap

  const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
    ({ className, size = 'md', src, alt, fallback, initials, ...props }, ref) => {
      const displayInitials = initials ?? getInitials(fallback ?? alt)

      return (
        <MantineAvatar
          ref={ref}
          data-slot="avatar"
          src={src}
          alt={alt ?? ''}
          size={sizeMap[size]}
          radius="xl"
          className={className}
          {...props}
        >
          {displayInitials}
        </MantineAvatar>
      )
    }
  )
  Avatar.displayName = 'Avatar'

  export { Avatar, avatarVariants }
  export type { AvatarProps, AvatarSize }

params_schema:
  {"output":"Componente Avatar que renderiza imagen de usuario circular con fallback a iniciales generadas"}


id: badge_ts_ui
name: badge
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Badge(props: BadgeProps & VariantProps<typeof badgeVariants>): JSX.Element
tags: ["badge","status","component","ui","indicator","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
example:
  ```tsx
  <Badge variant="success">Active</Badge>
  <Badge variant="error" size="sm">Error</Badge>
  ```

tests: []
file_path: frontend/functions/ui/badge.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["default","secondary","destructive","outline","ghost","link","success","warning","error","info"]
content_hash: 494db4cd19433eb18f2f21d449309fb65b50823d41ca521f24ca414268ce3f5c
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/badge.tsx
description:
  Badge con 10 variantes semánticas (default, secondary, destructive, outline, ghost, link, success, warning, error, info) y 2 tamaños. Mantine Badge.

props:
  [{"name":"variant","type":"'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'","required":false,"description":"Variante visual"},{"name":"size","type":"'default' | 'sm'","required":false,"description":"Tamaño"}]

notes:
  Usa Mantine Badge internamente. Las 10 variantes se mapean a combinaciones de variant+color de Mantine (filled, light, outline, subtle, transparent).

code:
  import * as React from 'react'
  import { Badge as MantineBadge } from '@mantine/core'

  type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'
  type BadgeSize = 'default' | 'sm'

  const variantMap: Record<BadgeVariant, { variant: string; color?: string }> = {
    default: { variant: 'filled' },
    secondary: { variant: 'light' },
    destructive: { variant: 'light', color: 'red' },
    outline: { variant: 'outline' },
    ghost: { variant: 'subtle' },
    link: { variant: 'transparent' },
    success: { variant: 'light', color: 'green' },
    warning: { variant: 'light', color: 'yellow' },
    error: { variant: 'light', color: 'red' },
    info: { variant: 'light', color: 'blue' },
  }

  /** Kept for backwards compatibility */
  const badgeVariants = variantMap

  interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
    variant?: BadgeVariant
    size?: BadgeSize
  }

  function Badge({ className, variant = 'default', size = 'default', children, ...props }: BadgeProps) {
    const mv = variantMap[variant]

    return (
      <MantineBadge
        data-slot="badge"
        variant={mv.variant}
        color={mv.color}
        size={size === 'sm' ? 'xs' : 'sm'}
        radius="xl"
        className={className}
        {...props}
      >
        {children}
      </MantineBadge>
    )
  }

  export { Badge, badgeVariants }
  export type { BadgeProps, BadgeVariant, BadgeSize }

params_schema:
  {"output":"Componente Badge que renderiza un indicador visual con 10 variantes semánticas de estado"}


id: bar_chart_ts_ui
name: bar_chart
kind: component
lang: ts
domain: ui
version: 1.1.0
purity: impure
signature: BarChart(props: BarChartProps): JSX.Element
description: Gráfico de barras @mantine/charts con multi-series, orientación horizontal/vertical y tooltips.
tags: ["chart","bar","visualization","mantine","component","ui"]
uses_functions: ["chart_container_ts_ui"]
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/charts","@mantine/core"]
tests: []
file_path: frontend/functions/ui/bar_chart.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["vertical","horizontal"]
content_hash: f74ab8d388d5c1e4a10e652b01451a3c104bc4ed8f702ced3fa707dd00b99539
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/bar-chart.tsx
example:
  ```tsx
  <BarChart data={data} xKey="category" yKey="sales" showLegend />
  <BarChart data={data} xKey="name" series={series} horizontal />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Array de datos"},{"name":"xKey","type":"string","required":true,"description":"Key del eje X/categoría"},{"name":"horizontal","type":"boolean","required":false,"description":"Orientación horizontal"},{"name":"series","type":"Series[]","required":false,"description":"Series de datos para multi-series"}]

notes:
  En modo `horizontal=true` se pasa `orientation="vertical"` a Mantine BarChart, que internamente intercambia los ejes.

code:
  import { BarChart as MantineBarChart } from '@mantine/charts'
  import { Paper } from '@mantine/core'
  import { type Series, getSeriesColor } from './chart_container'

  interface BarChartProps {
    data: Record<string, unknown>[]
    xKey: string
    yKey?: string
    series?: Series[]
    horizontal?: boolean
    showGrid?: boolean
    showLegend?: boolean
    height?: number
    xAxisFormatter?: (value: unknown) => string
    yAxisFormatter?: (value: unknown) => string
    valueFormatter?: (value: number) => string
  }

  function BarChartComponent({
    data, xKey, yKey, series, horizontal = false, showGrid = true, showLegend = false,
    height = 300, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(),
  }: BarChartProps) {
    const chartSeries = series
      ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
      : yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []

    return (
      <Paper p="md">
        <MantineBarChart
          h={height}
          data={data}
          dataKey={xKey}
          series={chartSeries}
          orientation={horizontal ? 'vertical' : 'horizontal'}
          gridAxis={showGrid ? 'xy' : 'none'}
          withLegend={showLegend}
          withTooltip
          valueFormatter={valueFormatter}
          xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
          yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
        />
      </Paper>
    )
  }

  export const BarChart = BarChartComponent
  export type { BarChartProps }

params_schema:
  {"output":"Componente JSX que renderiza un gráfico de barras vertical u horizontal con multi-series y tooltips"}


id: breadcrumb_ts_ui
name: breadcrumb
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Breadcrumb(props: BreadcrumbProps): JSX.Element
tags: ["breadcrumb","navigation","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/breadcrumb.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
props: [{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]
emits: []
framework: react
variant: []
content_hash: 6b133d8a503addb7a936c5da2d9f0715a10ce0e555965f8ff19203cdf295b7aa
description:
  Navegacion jerarquica con separadores, elipsis para paths largos y soporte para router links via asChild. Mantine Anchor/Text.

example:
  ```tsx
  <Breadcrumb>
    <BreadcrumbList>
      <BreadcrumbItem>
        <BreadcrumbLink href="/">Inicio</BreadcrumbLink>
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbLink href="/docs">Documentacion</BreadcrumbLink>
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbPage>Componentes</BreadcrumbPage>
      </BreadcrumbItem>
    </BreadcrumbList>
  </Breadcrumb>

  // Con elipsis para paths largos
  <Breadcrumb>
    <BreadcrumbList>
      <BreadcrumbItem>
        <BreadcrumbLink href="/">Inicio</BreadcrumbLink>
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbEllipsis />
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbPage>Pagina actual</BreadcrumbPage>
      </BreadcrumbItem>
    </BreadcrumbList>
  </Breadcrumb>
  ```

notes:
  Exports: Breadcrumb (nav), BreadcrumbList (ol via Group), BreadcrumbItem (li via Group), BreadcrumbLink (Mantine Anchor con asChild), BreadcrumbPage (Text aria-current=page), BreadcrumbSeparator (IconChevronRight por defecto, customizable), BreadcrumbEllipsis (IconDots). BreadcrumbLink acepta asChild para usar con Link de React Router o Next.js. Usa Tabler icons en vez de lucide-react.

code:
  import * as React from "react"
  import { Anchor, Text, Box } from "@mantine/core"
  import { IconChevronRight, IconDots } from "@tabler/icons-react"

  function Breadcrumb({ children, ...props }: React.ComponentPropsWithoutRef<"nav">) {
    return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props}>{children}</nav>
  }

  function BreadcrumbList({ className, children, ...props }: React.ComponentPropsWithoutRef<"ol">) {
    return (
      <ol data-slot="breadcrumb-list" style={{ listStyle: "none", display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, padding: 0, margin: 0 }} className={className} {...props}>
        {children}
      </ol>
    )
  }

  function BreadcrumbItem({ className, children, ...props }: React.ComponentPropsWithoutRef<"li">) {
    return (
      <li data-slot="breadcrumb-item" style={{ display: "flex", alignItems: "center", gap: 8 }} className={className} {...props}>
        {children}
      </li>
    )
  }

  function BreadcrumbLink({
    className,
    href,
    asChild,
    children,
    ...props
  }: React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean }) {
    if (asChild) {
      return (
        <Text data-slot="breadcrumb-link" component="span" size="sm" className={className} {...(props as React.ComponentPropsWithoutRef<"span">)}>
          {children}
        </Text>
      )
    }
    return (
      <Anchor data-slot="breadcrumb-link" href={href} size="sm" className={className} {...props}>
        {children}
      </Anchor>
    )
  }

  function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
    return (
      <Text
        data-slot="breadcrumb-page"
        component="span"
        size="sm"
        fw={500}
        role="link"
        aria-current="page"
        aria-disabled="true"
        className={className}
        {...props}
      />
    )
  }

  function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
    return (
      <Box
        data-slot="breadcrumb-separator"
        component="li"
        role="presentation"
        aria-hidden="true"
        className={className}
        style={{ display: "flex", alignItems: "center" }}
        {...props}
      >
        {children ?? <IconChevronRight size={14} />}
      </Box>
    )
  }

  function BreadcrumbEllipsis({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
    return (
      <Box
        data-slot="breadcrumb-ellipsis"
        component="span"
        role="presentation"
        aria-hidden="true"
        style={{ display: "flex", width: 36, height: 36, alignItems: "center", justifyContent: "center" }}
        className={className}
        {...props}
      >
        <IconDots size={16} />
        <span style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0,0,0,0)", whiteSpace: "nowrap", borderWidth: 0 }}>More</span>
      </Box>
    )
  }

  export { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator }

params_schema:
  {"output":"Componente Breadcrumb que renderiza navegación jerárquica con separadores, elipsis y soporte para router links"}


id: button_ts_ui
name: button
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Button(props: ButtonProps & VariantProps<typeof buttonVariants>): JSX.Element
tags: ["button","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/button.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onClick"]
framework: react
variant: ["default","outline","secondary","ghost","destructive","link"]
content_hash: 092cee02578ae6dc0bc83b0a5b00c4049b81d2ba1aaba4bbbb1adaf7211f2b9e
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/button.tsx
params_schema: {"output":"JSX.Element: botón renderizado con los estilos y comportamientos configurados"}
description:
  Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Mantine Button.

example:
  ```tsx
  <Button variant="outline" size="sm">Click me</Button>
  <Button variant="destructive">Delete</Button>
  <Button variant="ghost" size="icon"><TrashIcon /></Button>
  ```

props:
  [{"name":"variant","type":"'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'","required":false,"description":"Estilo visual del botón"},{"name":"size","type":"'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'","required":false,"description":"Tamaño del botón"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Componente base del sistema. Usa Mantine Button para accesibilidad completa (keyboard, ARIA). Las variantes se mapean a Mantine: default->filled, outline->outline, secondary->light, ghost->subtle, destructive->filled(red), link->transparent.

code:
  import * as React from 'react'
  import { Button as MantineButton } from '@mantine/core'

  type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
  type ButtonSize = 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'

  const variantMap: Record<ButtonVariant, { variant: string; color?: string }> = {
    default: { variant: 'filled' },
    outline: { variant: 'outline' },
    secondary: { variant: 'light' },
    ghost: { variant: 'subtle' },
    destructive: { variant: 'filled', color: 'red' },
    link: { variant: 'transparent' },
  }

  const sizeMap: Record<ButtonSize, { size: string; style?: React.CSSProperties }> = {
    default: { size: 'sm' },
    xs: { size: 'xs' },
    sm: { size: 'xs' },
    lg: { size: 'md' },
    icon: { size: 'sm', style: { width: 32, height: 32, padding: 0 } },
    'icon-xs': { size: 'xs', style: { width: 24, height: 24, padding: 0 } },
    'icon-sm': { size: 'xs', style: { width: 28, height: 28, padding: 0 } },
    'icon-lg': { size: 'md', style: { width: 36, height: 36, padding: 0 } },
  }

  /** Kept for backwards compatibility — maps variant names to Mantine equivalents */
  const buttonVariants = variantMap

  interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
    variant?: ButtonVariant
    size?: ButtonSize
    children?: React.ReactNode
  }

  function Button({
    className,
    variant = 'default',
    size = 'default',
    style,
    children,
    ...props
  }: ButtonProps) {
    const mv = variantMap[variant]
    const ms = sizeMap[size]

    return (
      <MantineButton
        data-slot="button"
        variant={mv.variant}
        color={mv.color}
        size={ms.size}
        radius="md"
        className={className}
        style={{ ...ms.style, ...style }}
        {...props}
      >
        {children}
      </MantineButton>
    )
  }

  export { Button, buttonVariants }
  export type { ButtonProps, ButtonVariant, ButtonSize }


id: card_ts_ui
name: card
kind: component
lang: ts
domain: ui
version: 1.1.0
purity: impure
tags: ["card","container","layout","component","ui","dashboard","dark"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/card.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["default","sm","borderless","ghost"]
content_hash: 3d507a1d09f63f802915ee3d6f0ab13a86849d1e368fbfd04a5fa2d6ce0fdd63
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/card.tsx
signature:
  Card(props: { size?: 'default' | 'sm'; variant?: 'default' | 'borderless' | 'ghost'; className?: string; children: ReactNode }): JSX.Element

description:
  Contenedor card con header, title, description, action, content y footer. Sistema de slots composable. Variantes default, borderless y ghost para dashboards dark.

example:
  ```tsx
  <Card>
    <CardHeader>
      <CardTitle>Título</CardTitle>
      <CardDescription>Descripción</CardDescription>
    </CardHeader>
    <CardContent>Contenido</CardContent>
    <CardFooter>Footer</CardFooter>
  </Card>

  {/* Dashboard dark — sin bordes */}
  <Card variant="borderless">
    <CardContent>Widget sin marco</CardContent>
  </Card>

  {/* Completamente transparente */}
  <Card variant="ghost">
    <CardContent>Sin fondo ni borde</CardContent>
  </Card>
  ```

props:
  [{"name":"size","type":"'default' | 'sm'","required":false,"description":"Tamaño del card"},{"name":"variant","type":"'default' | 'borderless' | 'ghost'","required":false,"description":"Variante visual. borderless quita borde/shadow, ghost además hace bg transparente"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables.

  Las variantes `borderless` y `ghost` eliminan el `ring-1` del borde por defecto. `ghost` además hace el fondo transparente. Alternativa con CSS global: `[data-slot="card"] { --tw-ring-opacity: 0; }` o `[data-variant="borderless"] { ring: 0 }` via `data-variant` attribute expuesto.

code:
  import * as React from 'react'
  import { Paper, Box, Text } from '@mantine/core'

  type CardVariant = 'default' | 'borderless' | 'ghost'

  function Card({
    className,
    size = 'default',
    variant = 'default',
    children,
    ...props
  }: React.ComponentProps<'div'> & { size?: 'default' | 'sm'; variant?: CardVariant }) {
    return (
      <Paper
        data-slot="card"
        data-size={size}
        data-variant={variant}
        withBorder={variant === 'default'}
        shadow={variant === 'default' ? 'xs' : undefined}
        radius="md"
        p={size === 'sm' ? 'sm' : 'md'}
        bg={variant === 'ghost' ? 'transparent' : undefined}
        className={className}
        {...props}
      >
        {children}
      </Paper>
    )
  }

  function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-header"
        pb="xs"
        className={className}
        {...props}
      />
    )
  }

  function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="card-title"
        fw={600}
        size="sm"
        className={className}
        {...props}
      />
    )
  }

  function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="card-description"
        size="sm"
        c="dimmed"
        className={className}
        {...props}
      />
    )
  }

  function CardAction({ className, style, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-action"
        style={{ position: 'absolute', top: 0, right: 0, ...style }}
        className={className}
        {...props}
      />
    )
  }

  function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-content"
        className={className}
        {...props}
      />
    )
  }

  function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-footer"
        pt="sm"
        mt="auto"
        style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
        className={className}
        {...props}
      />
    )
  }

  export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

params_schema:
  {"output":"Componente Card que renderiza un contenedor con slots composables (header, content, footer) y 3 variantes visuales"}


id: chart_container_ts_ui
name: chart_container
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element
description: Thin wrapper Paper y utilidades de colores/series para los charts @mantine/charts.
tags: ["chart","container","mantine","base","visualization","component","ui"]
uses_functions: []
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/chart_container.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default"]
content_hash: 289ff310f93689519a41311225239b7463e0cf44c817278c0404792b991386dd
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/chart-base.tsx
example:
  ```tsx
  import { ChartContainer, getSeriesColor, type Series } from './chart_container'

  <ChartContainer height={400}>
    <MantineLineChart ... />
  </ChartContainer>
  ```

props:
  [{"name":"height","type":"number | string","required":false,"description":"Altura del chart (default 300)"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Exporta: ChartContainer, defaultColors, getSeriesColor, Series. Wrapper fino sobre Mantine Paper para layout uniforme de charts.

code:
  import { Paper } from '@mantine/core'

  export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']

  export interface Series {
    key: string
    name: string
    color?: string
  }

  export function getSeriesColor(index: number, color?: string): string {
    return color || defaultColors[index % defaultColors.length]!
  }

  interface ChartContainerProps {
    children: React.ReactNode
    height?: number | string
  }

  export function ChartContainer({ children, height = 300 }: ChartContainerProps) {
    return (
      <Paper p="md" style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}>
        {children}
      </Paper>
    )
  }

  /** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
  export function ChartTooltipContent() { return null }
  /** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
  export function ChartTooltip() { return null }
  /** @deprecated Mantine charts handle legends internally. Kept for index.ts compat. */
  export function ChartLegend() { return null }

params_schema:
  {"output":"Componente ChartContainer Paper wrapper y utilidades getSeriesColor/Series para charts Mantine"}


id: checkbox_ts_ui
name: checkbox
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Checkbox(props: CheckboxProps): JSX.Element
description: Input booleano accesible con label opcional y variante indeterminate. Mantine Checkbox.
tags: ["checkbox","component","ui","interactive","form","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/checkbox.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onCheckedChange"]
framework: react
variant: []
content_hash: 8196320b87dfd584dcb6195f14125fa6a6427001d058586f3e627e02c13c573f
example:
  ```tsx
  // Basico
  <Checkbox label="Acepto los terminos" />

  // Controlado
  <Checkbox
    label="Seleccionar todos"
    checked={allSelected}
    indeterminate={someSelected}
    onCheckedChange={setAllSelected}
  />

  // Sin label
  <Checkbox checked={isActive} onCheckedChange={setIsActive} />
  ```

props:
  [{"name":"label","type":"string","required":false,"description":"Texto de etiqueta visible junto al checkbox"},{"name":"indeterminate","type":"boolean","required":false,"description":"Estado indeterminate (guion) en vez de checked/unchecked"},{"name":"checked","type":"boolean","required":false,"description":"Estado controlado del checkbox"},{"name":"defaultChecked","type":"boolean","required":false,"description":"Estado inicial no controlado"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el checkbox"},{"name":"onCheckedChange","type":"(checked: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"}]

notes:
  Usa Mantine Checkbox para accesibilidad completa (keyboard, ARIA). El estado indeterminate se muestra con un guion horizontal. El callback onCheckedChange se adapta desde el onChange nativo de Mantine.

code:
  import { Checkbox as MantineCheckbox } from "@mantine/core"

  interface CheckboxProps {
    label?: string
    indeterminate?: boolean
    className?: string
    labelClassName?: string
    checked?: boolean
    defaultChecked?: boolean
    disabled?: boolean
    onCheckedChange?: (checked: boolean) => void
    id?: string
  }

  function Checkbox({ className, label, id, indeterminate, checked, defaultChecked, disabled, onCheckedChange, ...props }: CheckboxProps) {
    return (
      <MantineCheckbox
        id={id}
        data-slot="checkbox"
        label={label}
        indeterminate={indeterminate}
        checked={checked}
        defaultChecked={defaultChecked}
        disabled={disabled}
        onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
        className={className}
        size="sm"
        {...props}
      />
    )
  }

  export { Checkbox }
  export type { CheckboxProps }

params_schema:
  {"output":"Componente Checkbox que renderiza input booleano accesible con label opcional y estado indeterminate"}


id: chip_ts_ui
name: chip
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Chip(props: ChipProps): JSX.Element
tags: ["chip","toggle","selection","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/chip.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["filled","outline","light"]
content_hash: bb6ff52eda5c53b81f97c635e9e60816dceb3510f83d2f79596e840b1f2864a4
description:
  Chip seleccionable con variantes filled/outline/light. ChipGroup para selección simple o múltiple. Wrapper sobre Mantine Chip.

example:
  ```tsx
  import { Chip, ChipGroup } from '@fn_library'

  // Chip individual controlado
  <Chip checked={active} onChange={setActive}>
    Activo
  </Chip>

  // ChipGroup selección simple (una sola opción)
  <ChipGroup value={selected} onChange={setSelected}>
    <Chip value="react">React</Chip>
    <Chip value="vue">Vue</Chip>
    <Chip value="svelte">Svelte</Chip>
  </ChipGroup>

  // ChipGroup selección múltiple
  <ChipGroup multiple value={tags} onChange={setTags}>
    <Chip value="frontend">Frontend</Chip>
    <Chip value="backend">Backend</Chip>
    <Chip value="devops">DevOps</Chip>
  </ChipGroup>

  // Con variante y color custom
  <Chip variant="outline" color="teal" size="lg">
    Destacado
  </Chip>
  ```

props:
  [{"name":"checked","type":"boolean","required":false,"description":"Estado seleccionado del chip (controlled)"},{"name":"onChange","type":"(checked: boolean) =\u003e void","required":false,"description":"Callback al cambiar el estado del chip"},{"name":"variant","type":"'filled' | 'outline' | 'light'","required":false,"description":"Estilo visual del chip"},{"name":"color","type":"string","required":false,"description":"Color del chip cuando está seleccionado"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del chip"},{"name":"children","type":"React.ReactNode","required":true,"description":"Contenido del chip — texto o elemento"}]

notes:
  - Wrapper directo sobre `Chip` de `@mantine/core` v9. Todas las props de Mantine Chip son válidas.
  - `ChipGroup` es un alias de `MantineChip.Group` — gestiona el estado de selección entre chips hijos.
  - En `ChipGroup` sin `multiple`, `value` es `string`. Con `multiple`, `value` es `string[]`.
  - Internamente cada `Chip` renderiza un checkbox/radio accesible oculto con label visual.

code:
  import { Chip as MantineChip, type ChipProps as MantineChipProps } from '@mantine/core'

  interface ChipProps extends MantineChipProps {}

  function Chip(props: ChipProps) {
    return <MantineChip {...props} />
  }

  const ChipGroup = MantineChip.Group

  export { Chip, ChipGroup }
  export type { ChipProps }

params_schema:
  {"output":"Componente Chip que renderiza un elemento seleccionable tipo badge, con ChipGroup para gestionar selección simple o múltiple entre varios chips"}


id: color_input_ts_ui
name: color_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ColorInput(props: ColorInputProps): JSX.Element
tags: ["color","picker","input","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/color_input.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onChange","onChangeEnd"]
framework: react
variant: []
content_hash: 09723ceffef9a2b9b2e7de24b2f716884647599c9c7ea6ebf193e7e7cd395136
params_schema: {"output":"Componente ColorInput que renderiza input de color con picker desplegable y swatches"}
description:
  Selector de color con picker, swatches predefinidos y eye dropper. Soporta hex, rgb, hsl con alpha. Wrapper sobre Mantine ColorInput.

example:
  ```tsx
  import { ColorInput } from '@fn_library/color_input'

  // Basico con hex (default)
  <ColorInput
    label="Color primario"
    placeholder="#228be6"
  />

  // Con swatches predefinidos
  <ColorInput
    label="Color de marca"
    swatches={['#2e2e2e', '#868e96', '#fa5252', '#e64980', '#be4bdb', '#228be6', '#15aabf', '#12b886', '#40c057', '#82c91e', '#fab005', '#fd7e14']}
    swatchesPerRow={7}
    placeholder="Elige un color"
  />

  // Con rgba y eye dropper
  <ColorInput
    label="Color con transparencia"
    format="rgba"
    withEyeDropper
    value="rgba(34, 139, 230, 0.5)"
    onChange={(value) => console.log(value)}
  />
  ```

props:
  [{"name":"format","type":"\"hex\" | \"hexa\" | \"rgb\" | \"rgba\" | \"hsl\" | \"hsla\"","required":false,"description":"Formato de color a usar en el valor. Por defecto hex."},{"name":"swatches","type":"string[]","required":false,"description":"Lista de colores predefinidos mostrados como swatches en el picker."},{"name":"withEyeDropper","type":"boolean","required":false,"description":"Muestra el boton eye dropper para seleccionar color de la pantalla."},{"name":"withPicker","type":"boolean","required":false,"description":"Muestra el color picker interactivo. Por defecto true."},{"name":"value","type":"string","required":false,"description":"Valor controlado del color en el formato especificado."},{"name":"onChange","type":"(value: string) =\u003e void","required":false,"description":"Callback invocado cuando el color cambia."},{"name":"label","type":"React.ReactNode","required":false,"description":"Etiqueta del campo."},{"name":"placeholder","type":"string","required":false,"description":"Placeholder del input de texto."},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input."},{"name":"size","type":"\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","required":false,"description":"Tamanio del componente."}]

notes:
  Wrapper directo sobre `ColorInput` de Mantine v9. Hereda todas las props de Mantine sin restricciones.

  El eye dropper (`withEyeDropper`) solo funciona en browsers que soporten la EyeDropper API (Chrome/Edge). En Firefox no aparece el boton automaticamente.

  Cuando `format` es `hex` o `hsl`, el valor no incluye canal alpha. Para transparencia usar `hexa`, `rgba` o `hsla`.

code:
  import { ColorInput as MantineColorInput, type ColorInputProps as MantineColorInputProps } from '@mantine/core'

  interface ColorInputProps extends MantineColorInputProps {}

  function ColorInput(props: ColorInputProps) {
    return <MantineColorInput {...props} />
  }

  export { ColorInput }
  export type { ColorInputProps }


id: command_ts_ui
name: command
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Command(props: CommandProps): JSX.Element
tags: ["command","search","combobox","component","ui","interactive"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/command.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: ["onValueChange"]
framework: react
variant: []
content_hash: 328d257adbc7692eca328e9f184ed9a300c7d81ccf87ed99f943e48b296acb32
description:
  Combobox de busqueda y seleccion estilo cmdk. Filtra items por query, soporta grupos, iconos y shortcuts. Incluye CommandSearch para uso de una linea.

example:
  ```tsx
  // Uso simple con CommandSearch
  const items = [
    { value: "react", label: "React", group: "Frameworks" },
    { value: "vue", label: "Vue", group: "Frameworks" },
    { value: "typescript", label: "TypeScript", group: "Lenguajes" },
  ]

  <CommandSearch
    items={items}
    placeholder="Buscar tecnologia..."
    onValueChange={(val) => console.log(val)}
  />

  // Composable para mayor control
  <Command>
    <CommandInput placeholder="Buscar..." value={query} onChange={(e) => setQuery(e.target.value)} />
    <CommandList>
      <CommandEmpty>Sin resultados.</CommandEmpty>
      <CommandGroup heading="Sugerencias">
        <CommandItem selected={selected === "1"} onSelect={() => setSelected("1")}>
          Opcion 1
          <CommandShortcut>⌘K</CommandShortcut>
        </CommandItem>
      </CommandGroup>
    </CommandList>
  </Command>
  ```

props:
  [{"name":"items","type":"CommandItem[]","required":true,"description":"Array de items con value, label, description, icon, disabled, group"},{"name":"value","type":"string","required":false,"description":"Valor seleccionado (controlado)"},{"name":"onValueChange","type":"(value: string) =\u003e void","required":false,"description":"Callback al seleccionar un item"},{"name":"placeholder","type":"string","required":false,"description":"Placeholder del input de busqueda (default: Search...)"},{"name":"emptyMessage","type":"string","required":false,"description":"Mensaje cuando no hay resultados (default: No results found.)"}]

notes:
  Implementacion propia (sin dependencia de cmdk) usando primitivos HTML nativos. CommandSearch es el wrapper de alto nivel con filtrado reactivo integrado. El filtrado es case-insensitive sobre label, description y value. Los grupos se renderizan en orden de aparicion en items.

code:
  import * as React from 'react'
  import { TextInput, Text, Box, ScrollArea } from '@mantine/core'
  import { IconSearch } from '@tabler/icons-react'

  interface CommandItemData {
    value: string
    label: string
    description?: string
    icon?: React.ReactNode
    disabled?: boolean
    group?: string
  }

  interface CommandProps {
    items: CommandItemData[]
    value?: string
    onValueChange?: (value: string) => void
    placeholder?: string
    emptyMessage?: string
    className?: string
    inputClassName?: string
    listClassName?: string
  }

  function Command({ className, children, ...props }: React.ComponentPropsWithoutRef<'div'>) {
    return <Box data-slot="command" className={className} {...props}>{children}</Box>
  }

  function CommandInput({ className, value, onChange, placeholder, ...props }: {
    className?: string
    value?: string
    onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
    placeholder?: string
  }) {
    return (
      <TextInput
        data-slot="command-input"
        leftSection={<IconSearch size={16} />}
        className={className}
        value={value}
        onChange={onChange}
        placeholder={placeholder}
        styles={{ input: { border: 'none', borderBottom: '1px solid var(--mantine-color-default-border)' } }}
        {...props}
      />
    )
  }

  function CommandList({ className, children }: { className?: string; children?: React.ReactNode }) {
    return (
      <ScrollArea.Autosize mah={300} data-slot="command-list" className={className}>
        {children}
      </ScrollArea.Autosize>
    )
  }

  function CommandEmpty({ className, children }: { className?: string; children?: React.ReactNode }) {
    return (
      <Text ta="center" c="dimmed" size="sm" py="xl" data-slot="command-empty" className={className}>
        {children}
      </Text>
    )
  }

  function CommandGroup({ className, heading, children }: { className?: string; heading?: string; children?: React.ReactNode }) {
    return (
      <Box data-slot="command-group" p={4} className={className}>
        {heading && <Text size="xs" fw={500} c="dimmed" px="sm" py={6}>{heading}</Text>}
        <div>{children}</div>
      </Box>
    )
  }

  function CommandSeparator({ className }: { className?: string }) {
    return <Box data-slot="command-separator" h={1} bg="var(--mantine-color-default-border)" mx={-4} className={className} />
  }

  function CommandItem({ className, selected, disabled, onSelect, children }: {
    className?: string
    selected?: boolean
    disabled?: boolean
    onSelect?: () => void
    children?: React.ReactNode
  }) {
    return (
      <Box
        data-slot="command-item"
        data-selected={selected}
        aria-disabled={disabled}
        role="option"
        aria-selected={selected}
        onClick={!disabled ? onSelect : undefined}
        px="sm"
        py={6}
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: 8,
          borderRadius: 'var(--mantine-radius-sm)',
          cursor: disabled ? 'not-allowed' : 'pointer',
          opacity: disabled ? 0.5 : 1,
          backgroundColor: selected ? 'var(--mantine-color-default-hover)' : undefined,
          fontSize: 'var(--mantine-font-size-sm)',
        }}
        className={className}
      >
        {children}
      </Box>
    )
  }

  function CommandShortcut({ className, children }: { className?: string; children?: React.ReactNode }) {
    return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
  }

  function CommandSearch({
    items,
    value,
    onValueChange,
    placeholder = 'Search...',
    emptyMessage = 'No results found.',
    className,
  }: CommandProps) {
    const [query, setQuery] = React.useState('')
    const [selectedValue, setSelectedValue] = React.useState(value ?? '')

    const filtered = React.useMemo(() => {
      if (!query) return items
      const q = query.toLowerCase()
      return items.filter(
        (item) =>
          item.label.toLowerCase().includes(q) ||
          item.description?.toLowerCase().includes(q) ||
          item.value.toLowerCase().includes(q)
      )
    }, [items, query])

    const groups = React.useMemo(() => {
      const map = new Map<string, CommandItemData[]>()
      for (const item of filtered) {
        const key = item.group ?? ''
        if (!map.has(key)) map.set(key, [])
        map.get(key)!.push(item)
      }
      return map
    }, [filtered])

    const handleSelect = (val: string) => {
      setSelectedValue(val)
      onValueChange?.(val)
    }

    return (
      <Command className={className}>
        <CommandInput
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={placeholder}
        />
        <CommandList>
          {filtered.length === 0 ? (
            <CommandEmpty>{emptyMessage}</CommandEmpty>
          ) : (
            Array.from(groups.entries()).map(([group, groupItems]) => (
              <CommandGroup key={group} heading={group || undefined}>
                {groupItems.map((item) => (
                  <CommandItem
                    key={item.value}
                    selected={selectedValue === item.value}
                    disabled={item.disabled}
                    onSelect={() => handleSelect(item.value)}
                  >
                    {item.icon && <span>{item.icon}</span>}
                    <span>{item.label}</span>
                    {item.description && (
                      <Text span size="xs" c="dimmed" ml="auto">{item.description}</Text>
                    )}
                  </CommandItem>
                ))}
              </CommandGroup>
            ))
          )}
        </CommandList>
      </Command>
    )
  }

  export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut }
  export type { CommandItemData, CommandProps }

params_schema:
  {"output":"Componente Command que renderiza combobox de búsqueda y selección con filtrado reactivo, grupos e iconos"}


id: crud_page_ts_ui
name: crud_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: crudPage<T>(props: CrudPageProps<T>): ReactElement
tags: ["crud","page","table","form","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/crud_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: fbdf8ffd14118a1551b4cc9a709c2dcdda146f387736e3849c6a048a59ce6fc0
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera una página CRUD completa con header, tabla con columnas configurables, botones de acción (add/edit/delete) y schema de formulario.

example:
  ```tsx
  crudPage({
    title: 'Users',
    subtitle: 'Manage system users',
    data: users,
    fields: [
      { key: 'name', label: 'Name', type: 'text', required: true },
      { key: 'email', label: 'Email', type: 'email', required: true },
      { key: 'role', label: 'Role', type: 'select', options: [{ label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }] },
    ],
    columns: [
      { key: 'name', label: 'Name' },
      { key: 'email', label: 'Email' },
      { key: 'role', label: 'Role', render: (v) => <Badge variant={v === 'admin' ? 'default' : 'secondary'}>{v}</Badge> },
    ],
    onAdd: handleAdd,
    onEdit: handleEdit,
    onDelete: handleDelete,
  })
  ```

notes:
  El schema de campos se almacena como data attribute para que un agente pueda leerlo y generar el formulario de diálogo correspondiente. La tabla incluye sorting visual implícito por columnas.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, Paper, Table, Button, ActionIcon, Center } from '@mantine/core'
  import { IconPlus, IconPencil, IconTrash } from '@tabler/icons-react'

  interface CrudField {
    key: string
    label: string
    type: 'text' | 'number' | 'email' | 'select' | 'textarea'
    required?: boolean
    options?: Array<{ label: string; value: string }>
    placeholder?: string
  }

  interface CrudPageProps<T extends Record<string, unknown>> {
    title: string
    subtitle?: string
    data: T[]
    fields: CrudField[]
    columns: Array<{
      key: keyof T
      label: string
      render?: (value: unknown, row: T) => React.ReactNode
    }>
    onAdd?: (item: Partial<T>) => void
    onEdit?: (item: T) => void
    onDelete?: (item: T) => void
    actions?: React.ReactNode
    className?: string
  }

  export function crudPage<T extends Record<string, unknown>>({
    title,
    subtitle,
    data,
    fields,
    columns,
    onAdd,
    onEdit,
    onDelete,
    actions,
  }: CrudPageProps<T>): React.ReactElement {
    return (
      <Stack gap="lg">
        {/* Header */}
        <Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Stack gap={4}>
            <Title order={2}>{title}</Title>
            {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
          </Stack>
          <Group gap="xs">
            {actions}
            {onAdd && (
              <Button size="xs" leftSection={<IconPlus size={16} />}>
                Add {title.replace(/s$/, '')}
              </Button>
            )}
          </Group>
        </Group>

        {/* Table */}
        <Paper withBorder radius="md">
          <Table highlightOnHover>
            <Table.Thead>
              <Table.Tr>
                {columns.map((col) => (
                  <Table.Th key={String(col.key)} fz="sm" fw={500} c="dimmed" px="md" py="sm">
                    {col.label}
                  </Table.Th>
                ))}
                {(onEdit || onDelete) && (
                  <Table.Th ta="right" fz="sm" fw={500} c="dimmed" px="md" py="sm">Actions</Table.Th>
                )}
              </Table.Tr>
            </Table.Thead>
            <Table.Tbody>
              {data.length === 0 ? (
                <Table.Tr>
                  <Table.Td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)}>
                    <Center h={96}>
                      <Text c="dimmed">No items yet.</Text>
                    </Center>
                  </Table.Td>
                </Table.Tr>
              ) : (
                data.map((row, i) => (
                  <Table.Tr key={i}>
                    {columns.map((col) => (
                      <Table.Td key={String(col.key)} px="md" py="sm" style={{ verticalAlign: 'middle' }}>
                        {col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')}
                      </Table.Td>
                    ))}
                    {(onEdit || onDelete) && (
                      <Table.Td px="md" py="sm" ta="right">
                        <Group gap={4} justify="flex-end">
                          {onEdit && (
                            <ActionIcon variant="subtle" size="sm" onClick={() => onEdit(row)}>
                              <IconPencil size={14} />
                            </ActionIcon>
                          )}
                          {onDelete && (
                            <ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(row)}>
                              <IconTrash size={14} />
                            </ActionIcon>
                          )}
                        </Group>
                      </Table.Td>
                    )}
                  </Table.Tr>
                ))
              )}
            </Table.Tbody>
          </Table>
        </Paper>

        {/* Form fields definition (for agent use) */}
        <div style={{ display: 'none' }} data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
      </Stack>
    )
  }

  export type { CrudPageProps, CrudField }

params_schema:
  {"params":[{"name":"props","desc":"Configuración CRUD: título, datos, columnas de tabla, campos de formulario y callbacks para add/edit/delete"}],"output":"Componente ReactElement que renderiza página CRUD completa con tabla y botones de acción"}


id: dashboard_layout_ts_ui
name: dashboard_layout
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: dashboardLayout(props: DashboardLayoutProps): ReactElement
tags: ["dashboard","layout","grid","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/dashboard_layout.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 7ffc204c545b63a545ebfa79fa9f0b01865540fc85c7db5f77a18aa9085aaf3e
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera un grid responsive de dashboard a partir de un array de widgets con span configurable. 1-4 columnas con auto-responsive.

example:
  ```tsx
  dashboardLayout({
    columns: 4,
    widgets: [
      { id: 'revenue', title: 'Revenue', content: <KPICard label="Revenue" value="$12k" /> },
      { id: 'users', title: 'Users', content: <KPICard label="Users" value={1234} /> },
      { id: 'chart', title: 'Trends', span: 2, content: <LineChart data={data} xKey="month" yKey="value" /> },
      { id: 'table', span: 4, content: <DataTable columns={cols} data={rows} /> },
    ]
  })
  ```

notes:
  Factory pura — dado el mismo input siempre genera el mismo JSX. Un agente puede construir dashboards completos pasando widgets como configuración declarativa.

code:
  import * as React from 'react'
  import { SimpleGrid, Paper, Text } from '@mantine/core'

  interface DashboardWidget {
    id: string
    title?: string
    span?: 1 | 2 | 3 | 4
    rowSpan?: 1 | 2
    content: React.ReactNode
  }

  interface DashboardLayoutProps {
    widgets: DashboardWidget[]
    columns?: 1 | 2 | 3 | 4
    gap?: 'sm' | 'md' | 'lg'
    className?: string
  }

  const gapMap = { sm: 'xs', md: 'md', lg: 'lg' } as const

  export function dashboardLayout({
    widgets,
    columns = 4,
    gap = 'md',
  }: DashboardLayoutProps): React.ReactElement {
    return (
      <SimpleGrid
        cols={{ base: 1, md: Math.min(columns, 2), lg: columns }}
        spacing={gapMap[gap]}
      >
        {widgets.map((widget) => (
          <Paper
            key={widget.id}
            p="md"
            withBorder
            shadow="xs"
            radius="md"
            style={{
              gridColumn: widget.span && widget.span > 1 ? `span ${widget.span}` : undefined,
              gridRow: widget.rowSpan === 2 ? 'span 2' : undefined,
            }}
          >
            {widget.title && (
              <Text size="sm" fw={500} c="dimmed" mb="sm">{widget.title}</Text>
            )}
            {widget.content}
          </Paper>
        ))}
      </SimpleGrid>
    )
  }

  export type { DashboardWidget, DashboardLayoutProps }

params_schema:
  {"params":[{"name":"props","desc":"Configuración de layout: número de columnas y array de widgets con id, título, contenido y span"}],"output":"Componente ReactElement que renderiza grid responsive de dashboard con ancho adaptable por widget"}


id: data_table_ts_ui
name: data_table
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DataTable(props: DataTableProps): JSX.Element
tags: ["table","data","heatmap","dashboard","component","ui","format","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/data_table.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default","heatmap"]
content_hash: 270afe0531e114d8b85eca295994907d565c2091bb1bb85c723345e644aeb9df
description:
  Tabla de datos con sticky header, overflow scroll, heatmap por columna, formato condicional (number/datetime/currency) y hover rows. Auto-detecta columnas desde la primera fila si no se proveen.

example:
  ```tsx
  // Tabla simple con auto-detección de columnas
  <DataTable data={rows} />

  // Con columnas definidas y heatmap
  <DataTable
    data={metrics}
    columns={[
      { key: 'domain', label: 'Domain' },
      { key: 'count', label: 'Functions', format: ',' },
      { key: 'pure_pct', label: 'Pure %', format: '.1f' },
    ]}
    heatmapColumns={['count', 'pure_pct']}
  />

  // Con formato moneda y fecha
  <DataTable
    data={transactions}
    columns={[
      { key: 'date', label: 'Date', format: 'datetime' },
      { key: 'amount', label: 'Amount', format: '$,.2f', align: 'right' },
      { key: 'description', label: 'Description' },
    ]}
  />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Filas de datos. Cada objeto es una fila."},{"name":"columns","type":"ColumnDef[]","required":false,"description":"Definición de columnas con key, label, format y align. Si se omite, se auto-detectan desde la primera fila."},{"name":"heatmapColumns","type":"string[]","required":false,"description":"Keys de columnas numéricas que deben colorearse por intensidad (azul oscuro=bajo, azul claro=alto)."},{"name":"maxHeight","type":"number | string","required":false,"description":"Altura máxima antes de scroll. Default 500px."},{"name":"loading","type":"boolean","required":false,"description":"Estado de carga. Muestra spinner si data está vacía."},{"name":"error","type":"Error | null","required":false,"description":"Error a mostrar si la carga falló."}]

notes:
  Extraido y generalizado desde `apps/rapid_dashboards/frontend/src/components/widgets/TableWidget.tsx`. El heatmap usa `useMemo` para calcular min/max por columna solo cuando cambian `data` o `heatmapColumns`. La alineación de celdas numéricas es automática (derecha) cuando el valor es `typeof 'number'`; se puede sobreescribir con el campo `align` en ColumnDef.

documentation:
  ## Formatos soportados (campo `format` en ColumnDef)

  | format | Ejemplo input | Output |
  |--------|--------------|--------|
  | `','` | `1234567` | `1,234,567` |
  | `',.2f'` | `1234.5` | `1,234.50` |
  | `'$,.2f'` | `1234.5` | `$1,234.50` |
  | `'.0f'` | `42.7` | `43` |
  | `'datetime'` | `'2026-04-01T12:00:00Z'` | `4/1/2026, 12:00:00 PM` |

code:
  import * as React from 'react'
  import { Table, Text, Center, Loader } from '@mantine/core'

  interface ColumnDef {
    key: string
    label: string
    /** Format string: ',.2f' | '$,.2f' | 'datetime' | ',' */
    format?: string
    /** Alignment override. Numbers default to right, strings to left. */
    align?: 'left' | 'right' | 'center'
  }

  interface DataTableProps {
    data: Record<string, unknown>[]
    columns?: ColumnDef[]
    /** Column keys that should be colored by value intensity (heatmap). */
    heatmapColumns?: string[]
    maxHeight?: number | string
    loading?: boolean
    error?: Error | null
  }

  function formatCell(value: unknown, format?: string): string {
    if (value == null) return '—'
    if (!format) return String(value)

    if (format === 'datetime' && !isNaN(Date.parse(String(value)))) {
      return new Date(String(value)).toLocaleString()
    }

    const num = Number(value)
    if (!isNaN(num)) {
      if (format.includes('f')) {
        const match = format.match(/\.(\d+)f/)
        const d = match ? parseInt(match[1]!) : 0
        let str = num.toFixed(d)
        if (format.includes(',')) {
          str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
        }
        if (format.startsWith('$')) str = '$' + str
        return str
      }
      if (format === ',') return num.toLocaleString()
    }
    return String(value)
  }

  function DataTableComponent({
    data,
    columns,
    heatmapColumns = [],
    maxHeight = 500,
    loading = false,
    error = null,
  }: DataTableProps) {
    // Auto-detect columns from first row if not provided
    const effectiveColumns: ColumnDef[] = (columns && columns.length > 0)
      ? columns
      : (data && data.length > 0)
        ? Object.keys(data[0]!).map(k => ({ key: k, label: k }))
        : []

    // Compute heatmap ranges per column
    const heatmapRanges = React.useMemo(() => {
      const ranges: Record<string, { min: number; max: number }> = {}
      if (heatmapColumns.length > 0 && data && data.length > 0) {
        for (const key of heatmapColumns) {
          const values = data.map(r => Number(r[key])).filter(n => !isNaN(n))
          if (values.length > 0) {
            ranges[key] = { min: Math.min(...values), max: Math.max(...values) }
          }
        }
      }
      return ranges
    }, [data, heatmapColumns])

    function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined {
      const range = heatmapRanges[key]
      if (!range || range.max === range.min) return undefined
      const num = Number(value)
      if (isNaN(num)) return undefined
      const t = (num - range.min) / (range.max - range.min)
      const alpha = 0.1 + t * 0.55
      return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
    }

    if (loading && (!data || data.length === 0)) {
      return (
        <Center h={200}>
          <Loader size="sm" />
        </Center>
      )
    }

    if (error) {
      return (
        <Center h={200}>
          <Text size="sm" c="red">{error.message}</Text>
        </Center>
      )
    }

    return (
      <Table.ScrollContainer minWidth={0} mah={maxHeight} type="scrollarea">
        <Table striped={false} highlightOnHover withTableBorder={false} withColumnBorders={false}>
          <Table.Thead style={{ position: 'sticky', top: 0, zIndex: 10, backgroundColor: 'var(--mantine-color-body)' }}>
            <Table.Tr>
              {effectiveColumns.map(col => (
                <Table.Th
                  key={col.key}
                  style={{ whiteSpace: 'nowrap' }}
                  fz="xs"
                  fw={500}
                  c="dimmed"
                  tt="uppercase"
                  py={6}
                  px="sm"
                >
                  {col.label}
                </Table.Th>
              ))}
            </Table.Tr>
          </Table.Thead>
          <Table.Tbody>
            {(data ?? []).map((row, i) => (
              <Table.Tr key={i}>
                {effectiveColumns.map(col => {
                  const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left')
                  return (
                    <Table.Td
                      key={col.key}
                      style={{ textAlign: align, fontFamily: 'var(--mantine-font-family-monospace)', ...heatmapStyle(col.key, row[col.key]) }}
                      fz="xs"
                      py={6}
                      px="sm"
                    >
                      {formatCell(row[col.key], col.format)}
                    </Table.Td>
                  )
                })}
              </Table.Tr>
            ))}
          </Table.Tbody>
        </Table>
        {(!data || data.length === 0) && (
          <Center py="xl">
            <Text size="sm" c="dimmed">No data</Text>
          </Center>
        )}
      </Table.ScrollContainer>
    )
  }

  export const DataTable = DataTableComponent
  export type { DataTableProps, ColumnDef }

params_schema:
  {"output":"Componente DataTable que renderiza tabla con sticky header, heatmap condicional y formato automático de datos"}


id: date_picker_input_ts_ui
name: date_picker_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DatePickerInput(props: DatePickerInputProps): JSX.Element
tags: ["date","picker","calendar","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/dates"]
tests: []
file_path: frontend/functions/ui/date_picker_input.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["default"]
content_hash: 6be456f1545db0a75befbf40c041bae937ba1a860363fd51c0f7aed18f08cd7a
description:
  Selector de fecha con input y calendario desplegable. Soporta fecha simple, múltiple y rango. Wrapper sobre Mantine DatePickerInput.

example:
  ```tsx
  import { DatePickerInput, DatePicker } from '@fn_library'
  import { useState } from 'react'

  // Fecha simple
  function SingleDateExample() {
    const [value, setValue] = useState<Date | null>(null)
    return (
      <DatePickerInput
        label="Fecha de inicio"
        placeholder="Selecciona una fecha"
        value={value}
        onChange={setValue}
        clearable
      />
    )
  }

  // Rango de fechas
  function RangeDateExample() {
    const [range, setRange] = useState<[Date | null, Date | null]>([null, null])
    return (
      <DatePickerInput
        type="range"
        label="Periodo"
        placeholder="Selecciona un rango"
        value={range}
        onChange={setRange}
        valueFormat="DD/MM/YYYY"
      />
    )
  }

  // Múltiples fechas
  function MultipleDateExample() {
    const [dates, setDates] = useState<Date[]>([])
    return (
      <DatePickerInput
        type="multiple"
        label="Días seleccionados"
        placeholder="Selecciona fechas"
        value={dates}
        onChange={setDates}
        minDate={new Date()}
      />
    )
  }

  // DatePicker inline (sin input)
  function InlineDateExample() {
    const [value, setValue] = useState<Date | null>(null)
    return (
      <DatePicker
        value={value}
        onChange={setValue}
      />
    )
  }
  ```

props:
  [{"name":"type","type":"'default' | 'multiple' | 'range'","required":false,"description":"Modo de selección — fecha simple, múltiples fechas, o rango de fechas"},{"name":"value","type":"DateValue | DateValue[] | [DateValue, DateValue] | null","required":false,"description":"Fecha o fechas seleccionadas (controlled)"},{"name":"onChange","type":"(value: DateValue | DateValue[] | [DateValue, DateValue] | null) =\u003e void","required":false,"description":"Callback al cambiar la selección de fecha"},{"name":"valueFormat","type":"string","required":false,"description":"Formato de la fecha mostrada en el input (ej: 'DD/MM/YYYY')"},{"name":"clearable","type":"boolean","required":false,"description":"Permite limpiar la selección de fecha"},{"name":"label","type":"string","required":false,"description":"Label del campo"},{"name":"placeholder","type":"string","required":false,"description":"Texto cuando no hay fecha seleccionada"},{"name":"minDate","type":"Date","required":false,"description":"Fecha mínima seleccionable"},{"name":"maxDate","type":"Date","required":false,"description":"Fecha máxima seleccionable"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilitar el selector"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  - Wrapper directo sobre `DatePickerInput` y `DatePicker` de `@mantine/dates` v9. Todas las props de Mantine son válidas.
  - Requiere importar `@mantine/dates/styles.css` — este wrapper ya lo incluye.
  - El prop `type` controla el modo: `'default'` (fecha simple), `'multiple'` (varias fechas), `'range'` (rango con inicio y fin).
  - `DatePicker` es el calendario inline sin input — útil para formularios donde el calendario debe estar siempre visible.
  - `valueFormat` acepta tokens de dayjs (ej: `'DD/MM/YYYY'`, `'MMMM D, YYYY'`).
  - Re-exporta también `DatePicker` de `@mantine/dates` con el mismo patrón de wrapper.

code:
  import { DatePickerInput as MantineDatePickerInput, DatePicker as MantineDatePicker } from '@mantine/dates'
  import type { DatePickerInputProps as MantineDatePickerInputProps, DatePickerProps as MantineDatePickerProps } from '@mantine/dates'
  import '@mantine/dates/styles.css'

  interface DatePickerInputProps extends MantineDatePickerInputProps<'default'> {}
  interface DatePickerProps extends MantineDatePickerProps<'default'> {}

  function DatePickerInput(props: DatePickerInputProps) {
    return <MantineDatePickerInput {...props} />
  }

  function DatePicker(props: DatePickerProps) {
    return <MantineDatePicker {...props} />
  }

  export { DatePickerInput, DatePicker }
  export type { DatePickerInputProps, DatePickerProps }

params_schema:
  {"params":[{"name":"props","desc":"Props del componente DatePickerInput — incluye type (modo de selección), value, onChange, valueFormat, clearable, label, placeholder, minDate, maxDate, disabled y size"}],"output":"Componente DatePickerInput que renderiza input con calendario para selección de fechas"}


id: detail_page_ts_ui
name: detail_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: detailPage(props: DetailPageProps): ReactElement
tags: ["detail","page","entity","timeline","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/detail_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 413d5548fbc3eb2711692b43ac67c28af8c912713edb02e7d17617e7114b0fbe
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera una página de detalle de entidad con header (avatar, badge, back), grid de campos, tabs con contadores y timeline de actividad.

example:
  ```tsx
  detailPage({
    title: 'John Doe',
    subtitle: 'john@example.com',
    badge: <Badge variant="success">Active</Badge>,
    onBack: () => router.back(),
    fields: [
      { label: 'Role', value: 'Administrator' },
      { label: 'Created', value: 'Mar 15, 2026' },
      { label: 'Bio', value: 'Full stack developer...', span: 2 },
    ],
    tabs: [
      { label: 'Projects', value: 'projects', count: 12, content: <ProjectList /> },
      { label: 'Activity', value: 'activity', count: 48, content: <ActivityList /> },
    ],
    activeTab: 'projects',
    timeline: [
      { id: '1', title: 'Deployed v2.1', timestamp: '2 hours ago', variant: 'success' },
      { id: '2', title: 'Updated settings', timestamp: 'Yesterday' },
      { id: '3', title: 'Created project', timestamp: 'Mar 10, 2026' },
    ],
  })
  ```

notes:
  Factory completa para páginas de detalle. Combina header con back/avatar/badge, grid de metadata, tabs con badges de conteo, y timeline de actividad con variantes de color semántico.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, ActionIcon, Box, Tabs, Badge, Timeline, SimpleGrid } from '@mantine/core'
  import { IconChevronLeft } from '@tabler/icons-react'

  interface DetailField {
    label: string
    value: React.ReactNode
    span?: 1 | 2
  }

  interface DetailTab {
    label: string
    value: string
    content: React.ReactNode
    count?: number
  }

  interface TimelineEvent {
    id: string
    title: string
    description?: string
    timestamp: string
    icon?: React.ReactNode
    variant?: 'default' | 'success' | 'warning' | 'error'
  }

  interface DetailPageProps {
    title: string
    subtitle?: string
    badge?: React.ReactNode
    avatar?: React.ReactNode
    actions?: React.ReactNode
    onBack?: () => void
    fields: DetailField[]
    tabs?: DetailTab[]
    activeTab?: string
    onTabChange?: (value: string) => void
    timeline?: TimelineEvent[]
    className?: string
  }

  const variantColors: Record<string, string> = {
    default: 'blue',
    success: 'green',
    warning: 'yellow',
    error: 'red',
  }

  export function detailPage({
    title, subtitle, badge, avatar, actions, onBack,
    fields, tabs, activeTab, onTabChange, timeline,
  }: DetailPageProps): React.ReactElement {
    return (
      <Stack gap="lg">
        {/* Header */}
        <Group justify="space-between" align="flex-start" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Group align="flex-start" gap="md">
            {onBack && (
              <ActionIcon variant="subtle" size="sm" onClick={onBack} mt={4}>
                <IconChevronLeft size={16} />
              </ActionIcon>
            )}
            {avatar && (
              <Box
                w={48}
                h={48}
                style={{ flexShrink: 0, overflow: 'hidden', borderRadius: '50%', backgroundColor: 'var(--mantine-color-default)' }}
              >
                {avatar}
              </Box>
            )}
            <Stack gap={4}>
              <Group gap="sm" align="center">
                <Title order={2}>{title}</Title>
                {badge}
              </Group>
              {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
            </Stack>
          </Group>
          {actions && <Group gap="xs">{actions}</Group>}
        </Group>

        {/* Fields grid */}
        <SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
          {fields.map((field, i) => (
            <Box key={i} style={field.span === 2 ? { gridColumn: 'span 2' } : undefined}>
              <Stack gap={4}>
                <Text size="sm" c="dimmed">{field.label}</Text>
                <Text size="sm" fw={500}>{field.value}</Text>
              </Stack>
            </Box>
          ))}
        </SimpleGrid>

        {/* Tabs */}
        {tabs && tabs.length > 0 && (
          <Stack gap="md">
            <Tabs value={activeTab} onChange={(v) => v && onTabChange?.(v)}>
              <Tabs.List>
                {tabs.map((tab) => (
                  <Tabs.Tab
                    key={tab.value}
                    value={tab.value}
                    rightSection={tab.count !== undefined ? <Badge size="xs" variant="filled" circle>{tab.count}</Badge> : undefined}
                  >
                    {tab.label}
                  </Tabs.Tab>
                ))}
              </Tabs.List>
            </Tabs>
            {tabs.find(t => t.value === activeTab)?.content}
          </Stack>
        )}

        {/* Timeline */}
        {timeline && timeline.length > 0 && (
          <Stack gap="sm">
            <Text size="sm" fw={500} c="dimmed">Activity</Text>
            <Timeline active={timeline.length - 1} bulletSize={12} lineWidth={2}>
              {timeline.map((event) => (
                <Timeline.Item
                  key={event.id}
                  color={variantColors[event.variant || 'default']}
                  title={<Text size="sm" fw={500}>{event.title}</Text>}
                >
                  {event.description && <Text size="xs" c="dimmed">{event.description}</Text>}
                  <Text size="xs" c="dimmed" opacity={0.7}>{event.timestamp}</Text>
                </Timeline.Item>
              ))}
            </Timeline>
          </Stack>
        )}
      </Stack>
    )
  }

  export type { DetailPageProps, DetailField, DetailTab, TimelineEvent }

params_schema:
  {"params":[{"name":"props","desc":"Configuración de página de detalle: título, avatar, badge, tabs, timeline y campos de metadata"}],"output":"Componente ReactElement que renderiza página de detalle con header, grid de campos y timeline de actividad"}


id: dialog_ts_ui
name: dialog
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Dialog(props: DialogRootProps): JSX.Element
tags: ["dialog","modal","overlay","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/dialog.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onOpenChange"]
framework: react
variant: ["default"]
content_hash: 8dd9f2cfdd8a9a435a7491a15f50f5156403ad77ec0213d196366f5c429d7444
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/dialog.tsx
description:
  Diálogo modal accesible con close button y sistema de slots (header, footer, title, description). Mantine Modal.

example:
  ```tsx
  <Dialog>
    <DialogTrigger asChild><Button>Open</Button></DialogTrigger>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Título</DialogTitle>
        <DialogDescription>Descripción</DialogDescription>
      </DialogHeader>
      <p>Contenido</p>
      <DialogFooter>
        <Button>Confirmar</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
  ```

props:
  [{"name":"showCloseButton","type":"boolean","required":false,"description":"Mostrar botón de cerrar (default true)"}]

notes:
  10 subcomponentes exportados. Mantine Modal para accesibilidad completa (focus trap, escape, click outside). DialogPortal y DialogOverlay son no-ops mantenidos por compatibilidad.

code:
  import * as React from 'react'
  import { Modal, Box, Text, Group } from '@mantine/core'

  interface DialogProps {
    open?: boolean
    onOpenChange?: (open: boolean) => void
    children: React.ReactNode
  }

  const DialogContext = React.createContext<{
    open: boolean
    setOpen: (open: boolean) => void
  }>({ open: false, setOpen: () => {} })

  function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
    const [internalOpen, setInternalOpen] = React.useState(false)
    const open = controlledOpen ?? internalOpen
    const setOpen = React.useCallback(
      (v: boolean) => {
        onOpenChange?.(v)
        if (controlledOpen === undefined) setInternalOpen(v)
      },
      [controlledOpen, onOpenChange],
    )
    return (
      <DialogContext.Provider value={{ open, setOpen }}>
        {children}
      </DialogContext.Provider>
    )
  }

  function DialogTrigger({ children, ...props }: React.ComponentProps<'button'>) {
    const { setOpen } = React.useContext(DialogContext)
    return (
      <button type="button" data-slot="dialog-trigger" onClick={() => setOpen(true)} {...props}>
        {children}
      </button>
    )
  }

  function DialogPortal({ children }: { children: React.ReactNode }) {
    return <>{children}</>
  }

  function DialogClose({ children, ...props }: React.ComponentProps<'button'>) {
    const { setOpen } = React.useContext(DialogContext)
    return (
      <button type="button" data-slot="dialog-close" onClick={() => setOpen(false)} {...props}>
        {children}
      </button>
    )
  }

  function DialogOverlay() {
    return null
  }

  function DialogContent({
    children,
    showCloseButton = true,
    className,
    ...props
  }: React.ComponentProps<'div'> & { showCloseButton?: boolean }) {
    const { open, setOpen } = React.useContext(DialogContext)
    return (
      <Modal
        opened={open}
        onClose={() => setOpen(false)}
        withCloseButton={showCloseButton}
        radius="md"
        padding="md"
        size="sm"
        centered
        data-slot="dialog-content"
        className={className}
        {...props}
      >
        {children}
      </Modal>
    )
  }

  function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
    return <Box data-slot="dialog-header" mb="xs" className={className} {...props} />
  }

  function DialogFooter({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Group
        data-slot="dialog-footer"
        justify="flex-end"
        gap="sm"
        mt="md"
        pt="sm"
        style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
        className={className}
        {...props}
      >
        {children}
      </Group>
    )
  }

  function DialogTitle({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="dialog-title"
        fw={500}
        size="md"
        className={className}
        {...props}
      >
        {children}
      </Text>
    )
  }

  function DialogDescription({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="dialog-description"
        size="sm"
        c="dimmed"
        className={className}
        {...props}
      >
        {children}
      </Text>
    )
  }

  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }

params_schema:
  {"output":"Componente Dialog que renderiza modal accesible con focus trap y sistema de slots composables via Mantine Modal"}


id: dropdown_menu_ts_ui
name: dropdown_menu
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DropdownMenu(props: DropdownMenuProps): JSX.Element
tags: ["dropdown","menu","component","ui","interactive","overlay","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/dropdown_menu.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onOpenChange"]
framework: react
variant: []
content_hash: 208935252cc7c63176607e43d2c89f566f0ecf17494e2475fb1d8fd55dd94134
description:
  Menu de acciones y contexto accesible con items, checkboxes, radios, separadores y submenus. Base-UI Menu primitive.

example:
  ```tsx
  <DropdownMenu>
    <DropdownMenuTrigger asChild>
      <Button variant="outline">Acciones</Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent>
      <DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
      <DropdownMenuSeparator />
      <DropdownMenuItem onActivate={() => console.log("Perfil")}>
        Perfil
      </DropdownMenuItem>
      <DropdownMenuCheckboxItem checked={showBookmarks} onCheckedChange={setShowBookmarks}>
        Marcadores
      </DropdownMenuCheckboxItem>
      <DropdownMenuSeparator />
      <DropdownMenuSub>
        <DropdownMenuSubTrigger>Mas opciones</DropdownMenuSubTrigger>
        <DropdownMenuSubContent>
          <DropdownMenuItem>Opcion A</DropdownMenuItem>
        </DropdownMenuSubContent>
      </DropdownMenuSub>
    </DropdownMenuContent>
  </DropdownMenu>
  ```

props:
  [{"name":"open","type":"boolean","required":false,"description":"Estado controlado de apertura"},{"name":"defaultOpen","type":"boolean","required":false,"description":"Estado inicial de apertura"},{"name":"onOpenChange","type":"(open: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"},{"name":"modal","type":"boolean","required":false,"description":"Comportamiento modal (default: true)"}]

notes:
  Exports: DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal.

code:
  import * as React from 'react'
  import { Menu, Text } from '@mantine/core'

  function DropdownMenu({ children, ...props }: { children: React.ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; modal?: boolean }) {
    return (
      <Menu
        opened={props.open}
        defaultOpened={props.defaultOpen}
        onChange={props.onOpenChange}
        withinPortal
        shadow="md"
      >
        {children}
      </Menu>
    )
  }

  function DropdownMenuTrigger({ children, ...props }: { children: React.ReactNode; asChild?: boolean; className?: string }) {
    return <Menu.Target {...props}>{children}</Menu.Target>
  }

  function DropdownMenuPortal({ children }: { children?: React.ReactNode }) {
    return <>{children}</>
  }

  function DropdownMenuContent({ children, className }: { children?: React.ReactNode; className?: string; sideOffset?: number }) {
    return <Menu.Dropdown className={className}>{children}</Menu.Dropdown>
  }

  function DropdownMenuItem({ children, className, inset, ...props }: {
    children?: React.ReactNode
    className?: string
    inset?: boolean
    onClick?: () => void
    onActivate?: () => void
    disabled?: boolean
  }) {
    return (
      <Menu.Item
        className={className}
        onClick={props.onClick ?? props.onActivate}
        disabled={props.disabled}
        pl={inset ? 'xl' : undefined}
      >
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuCheckboxItem({ children, className, checked, onCheckedChange, ...props }: {
    children?: React.ReactNode
    className?: string
    checked?: boolean
    onCheckedChange?: (checked: boolean) => void
    disabled?: boolean
  }) {
    return (
      <Menu.Item
        className={className}
        onClick={() => onCheckedChange?.(!checked)}
        disabled={props.disabled}
        leftSection={checked ? <span style={{ fontSize: 14 }}>✓</span> : <span style={{ width: 14 }} />}
      >
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuRadioGroup({ children }: { children?: React.ReactNode; value?: string; onValueChange?: (value: string) => void }) {
    return <>{children}</>
  }

  function DropdownMenuRadioItem({ children, className, value, ...props }: {
    children?: React.ReactNode
    className?: string
    value?: string
    disabled?: boolean
    onClick?: () => void
  }) {
    return (
      <Menu.Item className={className} onClick={props.onClick} disabled={props.disabled}>
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuGroup({ children }: { children?: React.ReactNode }) {
    return <>{children}</>
  }

  function DropdownMenuLabel({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) {
    return (
      <Menu.Label className={className} pl={inset ? 'xl' : undefined}>
        {children}
      </Menu.Label>
    )
  }

  function DropdownMenuSeparator({ className }: { className?: string }) {
    return <Menu.Divider className={className} />
  }

  function DropdownMenuShortcut({ children, className }: { children?: React.ReactNode; className?: string }) {
    return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
  }

  function DropdownMenuSub({ children }: { children?: React.ReactNode }) {
    return <>{children}</>
  }

  function DropdownMenuSubTrigger({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) {
    return (
      <Menu.Item className={className} pl={inset ? 'xl' : undefined}>
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuSubContent({ children, className }: { children?: React.ReactNode; className?: string }) {
    return <Menu.Dropdown className={className}>{children}</Menu.Dropdown>
  }

  export {
    DropdownMenu,
    DropdownMenuCheckboxItem,
    DropdownMenuContent,
    DropdownMenuGroup,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuPortal,
    DropdownMenuRadioGroup,
    DropdownMenuRadioItem,
    DropdownMenuSeparator,
    DropdownMenuShortcut,
    DropdownMenuSub,
    DropdownMenuSubContent,
    DropdownMenuSubTrigger,
    DropdownMenuTrigger,
  }

params_schema:
  {"output":"Componente DropdownMenu que renderiza menú desplegable accesible con items, checkboxes, radios y submenus"}


