diff --git a/.claude/skills/create-tui/SKILL.md b/.claude/skills/create-tui/SKILL.md new file mode 100644 index 0000000..8fd90a3 --- /dev/null +++ b/.claude/skills/create-tui/SKILL.md @@ -0,0 +1,55 @@ +--- +name: create-tui +description: Scaffoldea una aplicación TUI en Go usando DevFactory (bubbletea) para gestionar scripts, comandos, Makefile y builds de un repositorio +argument-hint: [nombre] [--path /ruta/destino] +disable-model-invocation: true +user-invocable: true +allowed-tools: Bash, Read, Write, Edit +--- + +# create-tui + +Genera un proyecto TUI completo en Go usando los componentes de DevFactory (`tui/` — bubbletea, lipgloss). El TUI resultante permite gestionar un repositorio: ejecutar scripts bash, comandos frecuentes, targets de Makefile y configuraciones de build. + +## Sintaxis + +```bash +/create-tui [nombre] [--path /ruta/destino] +``` + +- `nombre`: nombre del proyecto (kebab-case). Si no se da, se pregunta. +- `--path`: directorio destino. Default: directorio actual. + +## Flujo + +### 1. Ejecutar script de setup + +```bash +bash "${CLAUDE_SKILL_DIR}/setup-create-tui.sh" [nombre] [path] +``` + +### 2. Si el script reporta STATUS: CONFIGURED + +Informar al usuario que el proyecto TUI ya existe en esa ruta. + +### 3. Si el script reporta STATUS: READY + +Mostrar resumen: +- Estructura creada (app/, views/, config/) +- Cómo ejecutar: `make run` o `go run .` +- Cómo compilar: `make build` +- Cómo instalar: `make install` +- Navegación: flechas para moverse, Enter para interactuar, Esc/0 para volver, Esc desde menú principal para salir + +### 4. Si el script reporta STATUS: ERROR + +Mostrar el error y sugerir corrección. + +## Convenciones + +- Usa DevFactory como dependencia via `go.work` (componentes tui/, shell/, core/) +- Patrón Elm Architecture de bubbletea (Model → Update → View) +- `Result[T]` del core de DevFactory para manejo de errores +- Ejecución async de comandos via `tea.Cmd` +- Navegación: flechas + Enter + Esc/0 en todas las vistas +- El TUI opera sobre un directorio target (default: `.`, configurable por argumento) diff --git a/.claude/skills/create-tui/setup-create-tui.sh b/.claude/skills/create-tui/setup-create-tui.sh new file mode 100755 index 0000000..c4f46e3 --- /dev/null +++ b/.claude/skills/create-tui/setup-create-tui.sh @@ -0,0 +1,1481 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# setup-create-tui.sh — Scaffoldea aplicación TUI en Go con DevFactory +# Genera un TUI completo para gestionar scripts, comandos, Makefile y builds +# ============================================================================= + +# --- Colores --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_ok() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $1"; } + +# --- Parámetros --- +MODULE_NAME="${1:-}" +TARGET_PATH="${2:-.}" +DEVFACTORY_PATH="$HOME/.local_agentes/backend" +DEVFACTORY_MODULE="github.com/lucasdataproyects/devfactory" + +# --- Validar nombre --- +if [[ -z "$MODULE_NAME" ]]; then + log_error "Uso: setup-create-tui.sh [path]" + echo "STATUS: ERROR" + exit 1 +fi + +# Normalizar nombre a kebab-case +MODULE_NAME=$(echo "$MODULE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') +PROJECT_DIR="$TARGET_PATH/$MODULE_NAME" +GO_MODULE="github.com/lucasdataproyects/$MODULE_NAME" + +# --- Check estado existente --- +if [[ -f "$PROJECT_DIR/go.mod" ]]; then + log_warn "El proyecto TUI $MODULE_NAME ya existe en $PROJECT_DIR" + echo "STATUS: CONFIGURED" + exit 0 +fi + +# --- Verificar dependencias --- +log_step "Verificando dependencias..." + +if ! command -v go &>/dev/null; then + log_error "Go no está instalado. Instala Go 1.22+" + echo "STATUS: ERROR" + exit 1 +fi + +GO_VERSION=$(go version | grep -oP '\d+\.\d+' | head -1) +log_ok "Go $GO_VERSION encontrado" + +if [[ ! -d "$DEVFACTORY_PATH" ]]; then + log_error "DevFactory no encontrado en $DEVFACTORY_PATH — es necesario para los componentes TUI" + echo "STATUS: ERROR" + exit 1 +fi + +log_ok "DevFactory encontrado en $DEVFACTORY_PATH" + +# --- Crear estructura --- +log_step "Creando estructura del proyecto TUI '$MODULE_NAME'..." + +mkdir -p "$PROJECT_DIR"/{app,views,config} + +# ============================================================================= +# go.mod +# ============================================================================= +log_step "Generando go.mod..." +cat > "$PROJECT_DIR/go.mod" << EOF +module $GO_MODULE + +go 1.22.2 + +require $DEVFACTORY_MODULE v0.0.0 + +replace $DEVFACTORY_MODULE => $DEVFACTORY_PATH +EOF + +# ============================================================================= +# go.work +# ============================================================================= +log_step "Generando go.work con DevFactory local..." +cat > "$PROJECT_DIR/go.work" << EOF +go 1.22.2 + +use ( + . + $DEVFACTORY_PATH +) +EOF +log_ok "go.work enlazado a DevFactory" + +# ============================================================================= +# config/config.go +# ============================================================================= +log_step "Generando config/config.go..." +cat > "$PROJECT_DIR/config/config.go" << 'GOEOF' +package config + +import ( + "path/filepath" +) + +// CommandEntry representa un comando frecuente configurable. +type CommandEntry struct { + Name string + Command string + Description string +} + +// BuildConfig representa una configuración de build. +type BuildConfig struct { + Name string + Command string + Description string +} + +// Config contiene la configuración del TUI. +type Config struct { + RootDir string + Commands []CommandEntry + BuildConfigs []BuildConfig +} + +// New crea una configuración con defaults sensatos. +func New(dir string) Config { + absDir, err := filepath.Abs(dir) + if err != nil { + absDir = dir + } + return Config{ + RootDir: absDir, + Commands: []CommandEntry{ + {Name: "Go Build", Command: "go build ./...", Description: "Compilar todos los paquetes"}, + {Name: "Go Test", Command: "go test ./... -v", Description: "Ejecutar todos los tests"}, + {Name: "Go Vet", Command: "go vet ./...", Description: "Análisis estático del código"}, + {Name: "Git Status", Command: "git status", Description: "Estado del repositorio"}, + {Name: "Git Log", Command: "git log --oneline -10", Description: "Últimos 10 commits"}, + }, + BuildConfigs: []BuildConfig{ + {Name: "Debug", Command: "go build -gcflags='-N -l' -o build/debug .", Description: "Build con símbolos de debug"}, + {Name: "Release", Command: "go build -trimpath -ldflags='-s -w' -o build/release .", Description: "Build optimizado para producción"}, + {Name: "Linux AMD64", Command: "GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o build/app-linux-amd64 .", Description: "Cross-compile Linux 64-bit"}, + {Name: "Windows AMD64", Command: "GOOS=windows GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o build/app-windows-amd64.exe .", Description: "Cross-compile Windows 64-bit"}, + }, + } +} +GOEOF + +# ============================================================================= +# views/keys.go — Constantes de navegación compartidas +# ============================================================================= +log_step "Generando views/keys.go..." +cat > "$PROJECT_DIR/views/keys.go" << 'GOEOF' +package views + +// Constantes de teclas para navegación. +const ( + KeyQuit = "ctrl+c" + KeyEsc = "esc" + KeyBack = "0" +) + +// IsBack retorna true si la tecla es de retroceso (Esc o 0). +func IsBack(key string) bool { + return key == KeyEsc || key == KeyBack +} +GOEOF + +# ============================================================================= +# views/exec.go — Tipos de mensaje y comandos async +# ============================================================================= +log_step "Generando views/exec.go..." +cat > "$PROJECT_DIR/views/exec.go" << 'GOEOF' +package views + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// ExecResultMsg se envía cuando un comando termina de ejecutarse. +type ExecResultMsg struct { + Output string + Error string + ExitCode int + Label string +} + +// ScriptEntry representa un script descubierto. +type ScriptEntry struct { + Name string + Path string + Description string +} + +// DiscoverScriptsMsg contiene los scripts descubiertos. +type DiscoverScriptsMsg struct { + Scripts []ScriptEntry +} + +// MakeTarget representa un target de Makefile. +type MakeTarget struct { + Name string + Description string +} + +// DiscoverMakeTargetsMsg contiene los targets descubiertos. +type DiscoverMakeTargetsMsg struct { + Targets []MakeTarget +} + +// ExecCommand ejecuta un comando shell de forma asíncrona. +func ExecCommand(label, command, workDir string) tea.Cmd { + return func() tea.Msg { + cmd := exec.Command("bash", "-c", command) + cmd.Dir = workDir + out, err := cmd.CombinedOutput() + exitCode := 0 + errMsg := "" + if err != nil { + errMsg = err.Error() + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = 1 + } + } + return ExecResultMsg{ + Output: string(out), + Error: errMsg, + ExitCode: exitCode, + Label: label, + } + } +} + +// DiscoverScripts busca archivos .sh en el directorio raíz. +func DiscoverScripts(rootDir string) tea.Cmd { + return func() tea.Msg { + var scripts []ScriptEntry + skipDirs := map[string]bool{ + ".git": true, "node_modules": true, "vendor": true, + "build": true, ".venv": true, "__pycache__": true, + } + _ = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + if skipDirs[info.Name()] { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(info.Name(), ".sh") { + relPath, _ := filepath.Rel(rootDir, path) + desc := extractScriptDescription(path) + scripts = append(scripts, ScriptEntry{ + Name: relPath, + Path: path, + Description: desc, + }) + } + return nil + }) + return DiscoverScriptsMsg{Scripts: scripts} + } +} + +func extractScriptDescription(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + lines := strings.Split(string(data), "\n") + limit := len(lines) + if limit > 10 { + limit = 10 + } + for _, line := range lines[:limit] { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "# ") && !strings.HasPrefix(trimmed, "#!") { + return strings.TrimPrefix(trimmed, "# ") + } + } + return "" +} + +// DiscoverMakeTargets parsea el Makefile buscando targets. +func DiscoverMakeTargets(rootDir string) tea.Cmd { + return func() tea.Msg { + makefilePath := filepath.Join(rootDir, "Makefile") + data, err := os.ReadFile(makefilePath) + if err != nil { + return DiscoverMakeTargetsMsg{Targets: nil} + } + + var targets []MakeTarget + lines := strings.Split(string(data), "\n") + targetRegex := regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:`) + var lastComment string + + for _, line := range lines { + if strings.HasPrefix(line, "## ") { + lastComment = strings.TrimPrefix(line, "## ") + } else if match := targetRegex.FindStringSubmatch(line); match != nil { + target := match[1] + if target != ".PHONY" { + desc := lastComment + lastComment = "" + parts := strings.SplitN(desc, ":", 2) + if len(parts) == 2 { + desc = strings.TrimSpace(parts[1]) + } + targets = append(targets, MakeTarget{ + Name: target, + Description: desc, + }) + } + } else { + lastComment = "" + } + } + return DiscoverMakeTargetsMsg{Targets: targets} + } +} +GOEOF + +# ============================================================================= +# views/menu.go +# ============================================================================= +log_step "Generando views/menu.go..." +cat > "$PROJECT_DIR/views/menu.go" << GOEOF +package views + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "$DEVFACTORY_MODULE/tui" +) + +// View identifica la vista activa. +type View int + +const ( + ViewMenu View = iota + ViewScripts + ViewCommands + ViewMakefiles + ViewBuilds +) + +// MenuModel es el menú principal del TUI. +type MenuModel struct { + list tui.ListModel + done bool +} + +// NewMenu crea el menú principal con las 4 opciones. +func NewMenu() MenuModel { + items := []tui.ListItem{ + {Title: "Scripts", Description: "Buscar y ejecutar scripts .sh del repositorio", Value: ViewScripts}, + {Title: "Comandos", Description: "Ejecutar comandos frecuentes configurados", Value: ViewCommands}, + {Title: "Make", Description: "Ejecutar targets del Makefile", Value: ViewMakefiles}, + {Title: "Builds", Description: "Gestionar configuraciones de build", Value: ViewBuilds}, + } + list := tui.NewList(items) + list = list.WithHeight(10).WithWidth(60) + return MenuModel{list: list} +} + +// SelectedView retorna la vista seleccionada. +func (m MenuModel) SelectedView() View { + item := m.list.SelectedItem() + if item == nil { + return ViewMenu + } + if v, ok := item.Value.(View); ok { + return v + } + return ViewMenu +} + +// Init implementa tea.Model. +func (m MenuModel) Init() tea.Cmd { + return nil +} + +// Update implementa tea.Model. +func (m MenuModel) Update(msg tea.Msg) (MenuModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter", " ": + m.list.Select() + m.done = true + return m, nil + } + } + updated, cmd := m.list.Update(msg) + m.list = updated.(tui.ListModel) + return m, cmd +} + +// View implementa tea.Model. +func (m MenuModel) View() string { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("6")). + MarginBottom(1) + + title := titleStyle.Render("Menú Principal") + return title + "\n" + m.list.View() +} + +// IsDone retorna true si el usuario seleccionó una opción. +func (m MenuModel) IsDone() bool { + return m.done +} + +// Reset limpia el estado de selección. +func (m *MenuModel) Reset() { + m.done = false +} +GOEOF + +# ============================================================================= +# views/scripts.go +# ============================================================================= +log_step "Generando views/scripts.go..." +cat > "$PROJECT_DIR/views/scripts.go" << GOEOF +package views + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "$DEVFACTORY_MODULE/tui" +) + +type scriptsState int + +const ( + scriptsLoading scriptsState = iota + scriptsList + scriptsRunning + scriptsOutput +) + +// ScriptsModel gestiona la vista de scripts. +type ScriptsModel struct { + state scriptsState + list tui.FilteredListModel + spinner tui.SpinnerModel + rootDir string + output string + exitCode int + runLabel string + scrollOff int + viewHeight int +} + +// NewScripts crea la vista de scripts. +func NewScripts(rootDir string) ScriptsModel { + list := tui.NewFilteredList(nil, "Buscar script...") + list.ListModel = list.ListModel.WithHeight(15).WithWidth(60) + return ScriptsModel{ + state: scriptsLoading, + list: list, + spinner: tui.NewSpinner("Buscando scripts..."), + rootDir: rootDir, + viewHeight: 20, + } +} + +// Init lanza el descubrimiento de scripts. +func (m ScriptsModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Init(), DiscoverScripts(m.rootDir)) +} + +// Update implementa tea.Model. +func (m ScriptsModel) Update(msg tea.Msg) (ScriptsModel, tea.Cmd) { + switch msg := msg.(type) { + case DiscoverScriptsMsg: + items := make([]tui.ListItem, len(msg.Scripts)) + for i, s := range msg.Scripts { + items[i] = tui.ListItem{ + Title: s.Name, + Description: s.Description, + Value: s.Path, + } + } + m.list.ListModel.SetItems(items) + m.list = tui.NewFilteredList(items, "Buscar script...") + m.list.ListModel = m.list.ListModel.WithHeight(15).WithWidth(60) + m.state = scriptsList + return m, nil + + case ExecResultMsg: + m.output = msg.Output + if msg.Error != "" && msg.Output == "" { + m.output = msg.Error + } + m.exitCode = msg.ExitCode + m.runLabel = msg.Label + m.state = scriptsOutput + m.scrollOff = 0 + return m, nil + + case tea.KeyMsg: + switch m.state { + case scriptsList: + switch msg.String() { + case "enter": + item := m.list.SelectedItem() + if item != nil { + path := item.Value.(string) + m.state = scriptsRunning + m.spinner.SetMessage("Ejecutando " + item.Title + "...") + return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, "bash "+path, m.rootDir)) + } + } + case scriptsOutput: + switch msg.String() { + case "up", "k": + if m.scrollOff > 0 { + m.scrollOff-- + } + return m, nil + case "down", "j": + lines := strings.Split(m.output, "\n") + if m.scrollOff < len(lines)-m.viewHeight { + m.scrollOff++ + } + return m, nil + } + } + case tea.WindowSizeMsg: + m.viewHeight = msg.Height - 6 + } + + if m.state == scriptsLoading || m.state == scriptsRunning { + updated, cmd := m.spinner.Update(msg) + m.spinner = updated.(tui.SpinnerModel) + return m, cmd + } + + if m.state == scriptsList { + updated, cmd := m.list.Update(msg) + m.list = updated.(tui.FilteredListModel) + return m, cmd + } + + return m, nil +} + +// View implementa tea.Model. +func (m ScriptsModel) View() string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) + + switch m.state { + case scriptsLoading: + return titleStyle.Render("Scripts") + "\n\n" + m.spinner.View() + case scriptsList: + return titleStyle.Render("Scripts") + "\n\n" + m.list.View() + case scriptsRunning: + return titleStyle.Render("Scripts") + "\n\n" + m.spinner.View() + case scriptsOutput: + header := titleStyle.Render("Scripts > " + m.runLabel) + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + if m.exitCode != 0 { + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + } + status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode)) + + lines := strings.Split(m.output, "\n") + end := m.scrollOff + m.viewHeight + if end > len(lines) { + end = len(lines) + } + visible := lines + if m.scrollOff < len(lines) { + visible = lines[m.scrollOff:end] + } + content := strings.Join(visible, "\n") + + return header + " " + status + "\n\n" + content + } + return "" +} + +// HandleBack retorna true si la tecla es back y la vista puede retroceder. +func (m *ScriptsModel) HandleBack() bool { + if m.state == scriptsOutput { + m.state = scriptsList + m.output = "" + m.scrollOff = 0 + return false + } + if m.state == scriptsList { + return true + } + return false +} +GOEOF + +# ============================================================================= +# views/commands.go +# ============================================================================= +log_step "Generando views/commands.go..." +cat > "$PROJECT_DIR/views/commands.go" << GOEOF +package views + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "$GO_MODULE/config" + "$DEVFACTORY_MODULE/tui" +) + +type commandsState int + +const ( + commandsList commandsState = iota + commandsRunning + commandsOutput +) + +// CommandsModel gestiona la vista de comandos frecuentes. +type CommandsModel struct { + state commandsState + list tui.ListModel + spinner tui.SpinnerModel + rootDir string + output string + exitCode int + runLabel string + scrollOff int + viewHeight int +} + +// NewCommands crea la vista de comandos frecuentes. +func NewCommands(cfg config.Config) CommandsModel { + items := make([]tui.ListItem, len(cfg.Commands)) + for i, c := range cfg.Commands { + items[i] = tui.ListItem{ + Title: c.Name, + Description: c.Description + " — " + c.Command, + Value: c.Command, + } + } + list := tui.NewList(items) + list = list.WithHeight(12).WithWidth(60) + return CommandsModel{ + state: commandsList, + list: list, + spinner: tui.NewSpinner("Ejecutando..."), + rootDir: cfg.RootDir, + viewHeight: 20, + } +} + +// Init implementa tea.Model. +func (m CommandsModel) Init() tea.Cmd { + return nil +} + +// Update implementa tea.Model. +func (m CommandsModel) Update(msg tea.Msg) (CommandsModel, tea.Cmd) { + switch msg := msg.(type) { + case ExecResultMsg: + m.output = msg.Output + if msg.Error != "" && msg.Output == "" { + m.output = msg.Error + } + m.exitCode = msg.ExitCode + m.runLabel = msg.Label + m.state = commandsOutput + m.scrollOff = 0 + return m, nil + + case tea.KeyMsg: + switch m.state { + case commandsList: + if msg.String() == "enter" { + item := m.list.SelectedItem() + if item != nil { + cmd := item.Value.(string) + m.state = commandsRunning + m.spinner.SetMessage("Ejecutando " + item.Title + "...") + return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, cmd, m.rootDir)) + } + } + case commandsOutput: + switch msg.String() { + case "up", "k": + if m.scrollOff > 0 { + m.scrollOff-- + } + return m, nil + case "down", "j": + lines := strings.Split(m.output, "\n") + if m.scrollOff < len(lines)-m.viewHeight { + m.scrollOff++ + } + return m, nil + } + } + case tea.WindowSizeMsg: + m.viewHeight = msg.Height - 6 + } + + if m.state == commandsRunning { + updated, cmd := m.spinner.Update(msg) + m.spinner = updated.(tui.SpinnerModel) + return m, cmd + } + + if m.state == commandsList { + updated, cmd := m.list.Update(msg) + m.list = updated.(tui.ListModel) + return m, cmd + } + + return m, nil +} + +// View implementa tea.Model. +func (m CommandsModel) View() string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) + + switch m.state { + case commandsList: + return titleStyle.Render("Comandos") + "\n\n" + m.list.View() + case commandsRunning: + return titleStyle.Render("Comandos") + "\n\n" + m.spinner.View() + case commandsOutput: + header := titleStyle.Render("Comandos > " + m.runLabel) + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + if m.exitCode != 0 { + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + } + status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode)) + + lines := strings.Split(m.output, "\n") + end := m.scrollOff + m.viewHeight + if end > len(lines) { + end = len(lines) + } + visible := lines + if m.scrollOff < len(lines) { + visible = lines[m.scrollOff:end] + } + content := strings.Join(visible, "\n") + + return header + " " + status + "\n\n" + content + } + return "" +} + +// HandleBack retorna true si debe volver al menú. +func (m *CommandsModel) HandleBack() bool { + if m.state == commandsOutput { + m.state = commandsList + m.output = "" + m.scrollOff = 0 + return false + } + if m.state == commandsList { + return true + } + return false +} +GOEOF + +# ============================================================================= +# views/makefiles.go +# ============================================================================= +log_step "Generando views/makefiles.go..." +cat > "$PROJECT_DIR/views/makefiles.go" << GOEOF +package views + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "$DEVFACTORY_MODULE/tui" +) + +type makeState int + +const ( + makeLoading makeState = iota + makeList + makeRunning + makeOutput + makeEmpty +) + +// MakefilesModel gestiona la vista de Makefile targets. +type MakefilesModel struct { + state makeState + list tui.FilteredListModel + spinner tui.SpinnerModel + rootDir string + output string + exitCode int + runLabel string + scrollOff int + viewHeight int +} + +// NewMakefiles crea la vista de Makefile targets. +func NewMakefiles(rootDir string) MakefilesModel { + list := tui.NewFilteredList(nil, "Buscar target...") + list.ListModel = list.ListModel.WithHeight(15).WithWidth(60) + return MakefilesModel{ + state: makeLoading, + list: list, + spinner: tui.NewSpinner("Buscando targets..."), + rootDir: rootDir, + viewHeight: 20, + } +} + +// Init lanza el descubrimiento de targets. +func (m MakefilesModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Init(), DiscoverMakeTargets(m.rootDir)) +} + +// Update implementa tea.Model. +func (m MakefilesModel) Update(msg tea.Msg) (MakefilesModel, tea.Cmd) { + switch msg := msg.(type) { + case DiscoverMakeTargetsMsg: + if len(msg.Targets) == 0 { + m.state = makeEmpty + return m, nil + } + items := make([]tui.ListItem, len(msg.Targets)) + for i, t := range msg.Targets { + items[i] = tui.ListItem{ + Title: t.Name, + Description: t.Description, + Value: t.Name, + } + } + m.list = tui.NewFilteredList(items, "Buscar target...") + m.list.ListModel = m.list.ListModel.WithHeight(15).WithWidth(60) + m.state = makeList + return m, nil + + case ExecResultMsg: + m.output = msg.Output + if msg.Error != "" && msg.Output == "" { + m.output = msg.Error + } + m.exitCode = msg.ExitCode + m.runLabel = msg.Label + m.state = makeOutput + m.scrollOff = 0 + return m, nil + + case tea.KeyMsg: + switch m.state { + case makeList: + if msg.String() == "enter" { + item := m.list.SelectedItem() + if item != nil { + target := item.Value.(string) + m.state = makeRunning + m.spinner.SetMessage("Ejecutando make " + target + "...") + return m, tea.Batch(m.spinner.Init(), ExecCommand("make "+target, "make "+target, m.rootDir)) + } + } + case makeOutput: + switch msg.String() { + case "up", "k": + if m.scrollOff > 0 { + m.scrollOff-- + } + return m, nil + case "down", "j": + lines := strings.Split(m.output, "\n") + if m.scrollOff < len(lines)-m.viewHeight { + m.scrollOff++ + } + return m, nil + } + } + case tea.WindowSizeMsg: + m.viewHeight = msg.Height - 6 + } + + if m.state == makeLoading || m.state == makeRunning { + updated, cmd := m.spinner.Update(msg) + m.spinner = updated.(tui.SpinnerModel) + return m, cmd + } + + if m.state == makeList { + updated, cmd := m.list.Update(msg) + m.list = updated.(tui.FilteredListModel) + return m, cmd + } + + return m, nil +} + +// View implementa tea.Model. +func (m MakefilesModel) View() string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + + switch m.state { + case makeLoading: + return titleStyle.Render("Make") + "\n\n" + m.spinner.View() + case makeEmpty: + return titleStyle.Render("Make") + "\n\n" + warnStyle.Render("No se encontró Makefile en "+m.rootDir) + case makeList: + return titleStyle.Render("Make") + "\n\n" + m.list.View() + case makeRunning: + return titleStyle.Render("Make") + "\n\n" + m.spinner.View() + case makeOutput: + header := titleStyle.Render("Make > " + m.runLabel) + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + if m.exitCode != 0 { + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + } + status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode)) + + lines := strings.Split(m.output, "\n") + end := m.scrollOff + m.viewHeight + if end > len(lines) { + end = len(lines) + } + visible := lines + if m.scrollOff < len(lines) { + visible = lines[m.scrollOff:end] + } + content := strings.Join(visible, "\n") + + return header + " " + status + "\n\n" + content + } + return "" +} + +// HandleBack retorna true si debe volver al menú. +func (m *MakefilesModel) HandleBack() bool { + if m.state == makeOutput { + m.state = makeList + m.output = "" + m.scrollOff = 0 + return false + } + if m.state == makeList || m.state == makeEmpty { + return true + } + return false +} +GOEOF + +# ============================================================================= +# views/builds.go +# ============================================================================= +log_step "Generando views/builds.go..." +cat > "$PROJECT_DIR/views/builds.go" << GOEOF +package views + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "$GO_MODULE/config" + "$DEVFACTORY_MODULE/tui" +) + +type buildsState int + +const ( + buildsList buildsState = iota + buildsRunning + buildsOutput +) + +// BuildsModel gestiona la vista de builds. +type BuildsModel struct { + state buildsState + list tui.ListModel + spinner tui.SpinnerModel + rootDir string + output string + exitCode int + runLabel string + startTime time.Time + elapsed time.Duration + scrollOff int + viewHeight int +} + +// NewBuilds crea la vista de builds. +func NewBuilds(cfg config.Config) BuildsModel { + items := make([]tui.ListItem, len(cfg.BuildConfigs)) + for i, b := range cfg.BuildConfigs { + items[i] = tui.ListItem{ + Title: b.Name, + Description: b.Description, + Value: b.Command, + } + } + list := tui.NewList(items) + list = list.WithHeight(12).WithWidth(60) + return BuildsModel{ + state: buildsList, + list: list, + spinner: tui.NewSpinner("Building..."), + rootDir: cfg.RootDir, + viewHeight: 20, + } +} + +// Init implementa tea.Model. +func (m BuildsModel) Init() tea.Cmd { + return nil +} + +// Update implementa tea.Model. +func (m BuildsModel) Update(msg tea.Msg) (BuildsModel, tea.Cmd) { + switch msg := msg.(type) { + case ExecResultMsg: + m.elapsed = time.Since(m.startTime) + m.output = msg.Output + if msg.Error != "" && msg.Output == "" { + m.output = msg.Error + } + m.exitCode = msg.ExitCode + m.runLabel = msg.Label + m.state = buildsOutput + m.scrollOff = 0 + return m, nil + + case tea.KeyMsg: + switch m.state { + case buildsList: + if msg.String() == "enter" { + item := m.list.SelectedItem() + if item != nil { + cmd := item.Value.(string) + m.state = buildsRunning + m.startTime = time.Now() + m.spinner.SetMessage("Building " + item.Title + "...") + return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, cmd, m.rootDir)) + } + } + case buildsOutput: + switch msg.String() { + case "up", "k": + if m.scrollOff > 0 { + m.scrollOff-- + } + return m, nil + case "down", "j": + lines := strings.Split(m.output, "\n") + if m.scrollOff < len(lines)-m.viewHeight { + m.scrollOff++ + } + return m, nil + } + } + case tea.WindowSizeMsg: + m.viewHeight = msg.Height - 6 + } + + if m.state == buildsRunning { + updated, cmd := m.spinner.Update(msg) + m.spinner = updated.(tui.SpinnerModel) + return m, cmd + } + + if m.state == buildsList { + updated, cmd := m.list.Update(msg) + m.list = updated.(tui.ListModel) + return m, cmd + } + + return m, nil +} + +// View implementa tea.Model. +func (m BuildsModel) View() string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) + + switch m.state { + case buildsList: + return titleStyle.Render("Builds") + "\n\n" + m.list.View() + case buildsRunning: + return titleStyle.Render("Builds") + "\n\n" + m.spinner.View() + case buildsOutput: + header := titleStyle.Render("Builds > " + m.runLabel) + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + if m.exitCode != 0 { + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + } + status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode)) + elapsed := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fmt.Sprintf("(%s)", m.elapsed.Round(time.Millisecond))) + + lines := strings.Split(m.output, "\n") + end := m.scrollOff + m.viewHeight + if end > len(lines) { + end = len(lines) + } + visible := lines + if m.scrollOff < len(lines) { + visible = lines[m.scrollOff:end] + } + content := strings.Join(visible, "\n") + + return header + " " + status + " " + elapsed + "\n\n" + content + } + return "" +} + +// HandleBack retorna true si debe volver al menú. +func (m *BuildsModel) HandleBack() bool { + if m.state == buildsOutput { + m.state = buildsList + m.output = "" + m.scrollOff = 0 + return false + } + if m.state == buildsList { + return true + } + return false +} +GOEOF + +# ============================================================================= +# app/model.go — Modelo principal +# ============================================================================= +log_step "Generando app/model.go..." +cat > "$PROJECT_DIR/app/model.go" << GOEOF +package app + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "$GO_MODULE/config" + "$GO_MODULE/views" + "$DEVFACTORY_MODULE/tui" +) + +// Model es el modelo principal de la aplicación TUI. +type Model struct { + tui.BaseModel + config config.Config + currentView views.View + menu views.MenuModel + scripts views.ScriptsModel + commands views.CommandsModel + makefiles views.MakefilesModel + builds views.BuildsModel + width int + height int + scriptsInit bool + makeInit bool +} + +// New crea el modelo principal. +func New(cfg config.Config) Model { + return Model{ + BaseModel: tui.NewBaseModel(), + config: cfg, + currentView: views.ViewMenu, + menu: views.NewMenu(), + scripts: views.NewScripts(cfg.RootDir), + commands: views.NewCommands(cfg), + makefiles: views.NewMakefiles(cfg.RootDir), + builds: views.NewBuilds(cfg), + } +} + +// Init implementa tea.Model. +func (m Model) Init() tea.Cmd { + return nil +} + +// Update implementa tea.Model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.SetSize(msg.Width, msg.Height) + + case tea.KeyMsg: + key := msg.String() + + // Ctrl+C siempre sale + if key == views.KeyQuit { + return m, tea.Quit + } + + // Manejar back (Esc/0) + if views.IsBack(key) { + switch m.currentView { + case views.ViewMenu: + return m, tea.Quit + case views.ViewScripts: + if m.scripts.HandleBack() { + m.currentView = views.ViewMenu + m.menu.Reset() + return m, nil + } + return m, nil + case views.ViewCommands: + if m.commands.HandleBack() { + m.currentView = views.ViewMenu + m.menu.Reset() + return m, nil + } + return m, nil + case views.ViewMakefiles: + if m.makefiles.HandleBack() { + m.currentView = views.ViewMenu + m.menu.Reset() + return m, nil + } + return m, nil + case views.ViewBuilds: + if m.builds.HandleBack() { + m.currentView = views.ViewMenu + m.menu.Reset() + return m, nil + } + return m, nil + } + } + } + + // Delegar al sub-modelo activo + var cmd tea.Cmd + switch m.currentView { + case views.ViewMenu: + m.menu, cmd = m.menu.Update(msg) + if m.menu.IsDone() { + selected := m.menu.SelectedView() + m.currentView = selected + m.menu.Reset() + switch selected { + case views.ViewScripts: + if !m.scriptsInit { + m.scriptsInit = true + return m, m.scripts.Init() + } + case views.ViewMakefiles: + if !m.makeInit { + m.makeInit = true + return m, m.makefiles.Init() + } + } + return m, nil + } + case views.ViewScripts: + m.scripts, cmd = m.scripts.Update(msg) + case views.ViewCommands: + m.commands, cmd = m.commands.Update(msg) + case views.ViewMakefiles: + m.makefiles, cmd = m.makefiles.Update(msg) + case views.ViewBuilds: + m.builds, cmd = m.builds.Update(msg) + } + + return m, cmd +} + +// View implementa tea.Model. +func (m Model) View() string { + headerStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("0")). + Background(lipgloss.Color("6")). + Padding(0, 1). + Width(m.width) + + header := headerStyle.Render(fmt.Sprintf(" TUI Manager — %s", m.config.RootDir)) + + var content string + switch m.currentView { + case views.ViewMenu: + content = m.menu.View() + case views.ViewScripts: + content = m.scripts.View() + case views.ViewCommands: + content = m.commands.View() + case views.ViewMakefiles: + content = m.makefiles.View() + case views.ViewBuilds: + content = m.builds.View() + } + + statusStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Width(m.width) + + var hints string + switch m.currentView { + case views.ViewMenu: + hints = "↑↓: navegar | Enter: seleccionar | Esc: salir" + default: + hints = "↑↓: navegar | Enter: ejecutar | /: filtrar | Esc/0: volver" + } + statusBar := statusStyle.Render(hints) + + return header + "\n\n" + content + "\n\n" + statusBar +} +GOEOF + +# ============================================================================= +# main.go +# ============================================================================= +log_step "Generando main.go..." +cat > "$PROJECT_DIR/main.go" << GOEOF +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "$GO_MODULE/app" + "$GO_MODULE/config" +) + +func main() { + dir := "." + if len(os.Args) > 1 { + dir = os.Args[1] + } + + cfg := config.New(dir) + model := app.New(cfg) + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} +GOEOF + +# ============================================================================= +# Makefile +# ============================================================================= +log_step "Generando Makefile..." +cat > "$PROJECT_DIR/Makefile" << MKEOF +.PHONY: run build test clean install help + +MODULE_NAME := $MODULE_NAME +BUILD_DIR := build + +## run: Ejecutar el TUI en modo desarrollo +run: + go run . . + +## build: Compilar el binario +build: + @mkdir -p \$(BUILD_DIR) + go build -trimpath -ldflags='-s -w' -o \$(BUILD_DIR)/\$(MODULE_NAME) . + @echo "Built \$(BUILD_DIR)/\$(MODULE_NAME)" + +## test: Ejecutar tests +test: + go test ./... -v + +## clean: Limpiar artefactos +clean: + rm -rf \$(BUILD_DIR) + +## install: Instalar en ~/.local/bin +install: build + cp \$(BUILD_DIR)/\$(MODULE_NAME) \$(HOME)/.local/bin/\$(MODULE_NAME) + @echo "Installed to \$(HOME)/.local/bin/\$(MODULE_NAME)" + +## tidy: go mod tidy +tidy: + go mod tidy + +## help: Mostrar esta ayuda +help: + @grep -E '^## ' Makefile | sed 's/## //' | column -t -s ':' +MKEOF + +# ============================================================================= +# .gitignore +# ============================================================================= +cat > "$PROJECT_DIR/.gitignore" << 'EOF' +build/ +*.exe +*.dll +*.so +*.dylib +EOF + +# ============================================================================= +# go mod tidy +# ============================================================================= +log_step "Ejecutando go mod tidy..." +cd "$PROJECT_DIR" +go mod tidy 2>/dev/null || log_warn "go mod tidy falló — revisa el go.work" + +# ============================================================================= +# Resumen +# ============================================================================= +echo "" +log_ok "Proyecto TUI '$MODULE_NAME' creado en $PROJECT_DIR" +echo "" +echo -e "${CYAN}Estructura:${NC}" +echo " $MODULE_NAME/" +echo " ├── main.go — Entry point (fullscreen TUI)" +echo " ├── app/" +echo " │ └── model.go — Modelo principal y navegación" +echo " ├── views/" +echo " │ ├── keys.go — Constantes de teclas" +echo " │ ├── exec.go — Comandos async y descubrimiento" +echo " │ ├── menu.go — Menú principal (4 opciones)" +echo " │ ├── scripts.go — Buscar y ejecutar scripts .sh" +echo " │ ├── commands.go — Comandos frecuentes" +echo " │ ├── makefiles.go — Targets del Makefile" +echo " │ └── builds.go — Configuraciones de build" +echo " ├── config/" +echo " │ └── config.go — Configuración (editable)" +echo " ├── Makefile — run, build, test, install" +echo " └── go.work — Enlace a DevFactory" +echo "" +echo -e "${CYAN}Comandos:${NC}" +echo " make run — Ejecutar el TUI" +echo " make build — Compilar binario" +echo " make install — Instalar en ~/.local/bin" +echo " make test — Ejecutar tests" +echo "" +echo -e "${CYAN}Navegación:${NC}" +echo " ↑↓ — Moverse por las listas" +echo " Enter — Seleccionar / ejecutar" +echo " / — Filtrar (en scripts y make)" +echo " Esc / 0 — Volver atrás" +echo " Esc (menú) — Salir del TUI" +echo "" +echo "STATUS: READY" diff --git a/.claude/skills/init-frontend/SKILL.md b/.claude/skills/init-frontend/SKILL.md new file mode 100644 index 0000000..21875ac --- /dev/null +++ b/.claude/skills/init-frontend/SKILL.md @@ -0,0 +1,62 @@ +--- +name: init-frontend +description: Inicializa proyecto frontend (React/Vite) o desktop (Wails) con Frontend_Library +disable-model-invocation: true +user-invocable: true +allowed-tools: Bash, Read, Write, Edit +--- + +# init-frontend + +Inicializa un proyecto frontend (webapp React/Vite) o desktop (Wails + Go + React). Coherente con Frontend_Library y el stack del frontend-lib/build-wails agents. + +## Sintaxis + +```bash +/init-frontend [nombre] [--wails] [--path /ruta/destino] +``` + +- `nombre`: nombre del proyecto (kebab-case). Si no se da, se pregunta. +- `--wails`: modo desktop con Wails (Go backend + React frontend). Sin flag = webapp pura. +- `--path`: directorio destino. Default: directorio actual. + +## Flujo + +### 1. Ejecutar script de setup + +```bash +bash "${CLAUDE_SKILL_DIR}/setup-frontend.sh" [nombre] [--wails] [path] +``` + +### 2. Si el script reporta STATUS: CONFIGURED + +Informar al usuario que el proyecto ya existe. + +### 3. Si el script reporta STATUS: READY + +Mostrar resumen según modo: + +**Webapp:** +- `pnpm dev` para desarrollo +- `pnpm build` para producción +- Frontend_Library linkeada via pnpm + +**Wails:** +- `make dev` para desarrollo con hot reload +- `make build` para compilar +- Frontend_Library + DevFactory integrados +- Bindings Go→TS auto-generados + +### 4. Si el script reporta STATUS: ERROR + +Mostrar el error y sugerir corrección. + +## Convenciones + +- pnpm exclusivamente (no npm ni yarn) +- React 19 + TypeScript + Vite + Tailwind CSS 4 +- @anthropic/frontend-lib via pnpm link +- Temas OKLCH con semantic tokens +- Phosphor Icons +- Vite dedupe obligatorio para react/react-dom +- En modo Wails: go.work con DevFactory, patrón pure core / impure shell diff --git a/.claude/skills/init-frontend/setup-frontend.sh b/.claude/skills/init-frontend/setup-frontend.sh new file mode 100755 index 0000000..c102acc --- /dev/null +++ b/.claude/skills/init-frontend/setup-frontend.sh @@ -0,0 +1,438 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# setup-frontend.sh — Inicializa proyecto React/Vite o Wails desktop +# Coherente con Frontend_Library + DevFactory + build-wails agent +# ============================================================================= + +# --- Colores --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_ok() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $1"; } + +# --- Parámetros --- +PROJECT_NAME="" +WAILS_MODE=false +TARGET_PATH="." + +while [[ $# -gt 0 ]]; do + case "$1" in + --wails) WAILS_MODE=true; shift ;; + --path) TARGET_PATH="$2"; shift 2 ;; + -*) log_error "Flag desconocido: $1"; echo "STATUS: ERROR"; exit 1 ;; + *) PROJECT_NAME="$1"; shift ;; + esac +done + +# --- Rutas de librerías --- +FRONTEND_LIB="$HOME/.local_agentes/frontend/frontend" +DEVFACTORY_PATH="$HOME/.local_agentes/backend" +TEMPLATES_DIR="$HOME/.local_agentes/frontend/templates/base" +WAILS_TEMPLATES="$HOME/.claude/agents/build-wails/templates" + +# --- Validar nombre --- +if [[ -z "$PROJECT_NAME" ]]; then + log_error "Uso: setup-frontend.sh [--wails] [--path /ruta]" + echo "STATUS: ERROR" + exit 1 +fi + +# Normalizar +PROJECT_NAME=$(echo "$PROJECT_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') +PROJECT_DIR="$TARGET_PATH/$PROJECT_NAME" + +# --- Check estado existente --- +if [[ -f "$PROJECT_DIR/package.json" ]] || [[ -f "$PROJECT_DIR/wails.json" ]]; then + log_warn "El proyecto $PROJECT_NAME ya existe en $PROJECT_DIR" + echo "STATUS: CONFIGURED" + exit 0 +fi + +# --- Verificar dependencias --- +log_step "Verificando dependencias..." + +if ! command -v pnpm &>/dev/null; then + log_error "pnpm no está instalado. Instala con: npm install -g pnpm" + echo "STATUS: ERROR" + exit 1 +fi +log_ok "pnpm $(pnpm --version) encontrado" + +if ! command -v node &>/dev/null; then + log_error "Node.js no encontrado" + echo "STATUS: ERROR" + exit 1 +fi +log_ok "Node $(node --version) encontrado" + +if [[ "$WAILS_MODE" == true ]]; then + if ! command -v wails &>/dev/null; then + log_error "Wails no está instalado. Instala con: go install github.com/wailsapp/wails/v2/cmd/wails@latest" + echo "STATUS: ERROR" + exit 1 + fi + log_ok "Wails $(wails version 2>/dev/null | head -1 || echo 'v2.x') encontrado" + + if ! command -v go &>/dev/null; then + log_error "Go no encontrado (requerido para Wails)" + echo "STATUS: ERROR" + exit 1 + fi + log_ok "Go $(go version | grep -oP '\d+\.\d+' | head -1) encontrado" +fi + +if [[ ! -d "$FRONTEND_LIB" ]]; then + log_warn "Frontend_Library no encontrada en $FRONTEND_LIB — se creará sin link" + HAS_FRONTEND_LIB=false +else + log_ok "Frontend_Library encontrada" + HAS_FRONTEND_LIB=true +fi + +# ============================================================================ +# MODO WAILS — Desktop app (Go + React) +# ============================================================================ +if [[ "$WAILS_MODE" == true ]]; then + log_step "Creando proyecto Wails '$PROJECT_NAME'..." + + # Usar wails init con template react-ts + wails init -n "$PROJECT_NAME" -t react-ts -d "$TARGET_PATH" 2>/dev/null + + cd "$PROJECT_DIR" + + # --- go.work con DevFactory --- + if [[ -d "$DEVFACTORY_PATH" ]]; then + log_step "Configurando go.work con DevFactory..." + cat > go.work << EOF +go 1.22 + +use ( + . + $DEVFACTORY_PATH +) +EOF + log_ok "DevFactory enlazado via go.work" + fi + + # --- Configurar frontend con pnpm --- + log_step "Configurando frontend con pnpm..." + cd frontend + + # Reemplazar npm por pnpm en wails.json + cd .. + if [[ -f "wails.json" ]]; then + sed -i 's/"npm install"/"pnpm install"/g' wails.json + sed -i 's/"npm run dev"/"pnpm dev"/g' wails.json + sed -i 's/"npm run build"/"pnpm build"/g' wails.json + fi + cd frontend + + # Instalar con pnpm + rm -f package-lock.json 2>/dev/null || true + pnpm install + + # --- Linkear Frontend_Library --- + if [[ "$HAS_FRONTEND_LIB" == true ]]; then + log_step "Linkeando Frontend_Library..." + pnpm add "@anthropic/frontend-lib@link:$FRONTEND_LIB" + log_ok "@anthropic/frontend-lib linkeada" + fi + + # --- Instalar Tailwind CSS 4 --- + log_step "Instalando Tailwind CSS 4..." + pnpm add -D tailwindcss @tailwindcss/vite + + # --- Configurar vite.config.ts con dedupe y tailwind --- + log_step "Configurando Vite (dedupe + tailwind)..." + cat > vite.config.ts << 'VEOF' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + '@wails': resolve(__dirname, './wailsjs'), + }, + dedupe: ['react', 'react-dom'], + }, +}) +VEOF + + # --- CSS base con Tailwind --- + cat > src/style.css << 'CSSEOF' +@import "tailwindcss"; +CSSEOF + + # --- Instalar Phosphor Icons --- + pnpm add @phosphor-icons/react + + cd .. + + # --- Makefile (basado en build-wails agent) --- + log_step "Generando Makefile..." + cat > Makefile << 'MKEOF' +.PHONY: dev dev-debug build build-prod build-linux build-windows build-all clean generate doctor + +APP_NAME := $(shell basename $(CURDIR)) + +## dev: Desarrollo con hot reload +dev: + wails dev + +## dev-debug: Desarrollo con DevTools +dev-debug: + wails dev -devtools + +## build: Build para plataforma actual +build: + wails build + +## build-prod: Build optimizado para producción +build-prod: + wails build -clean -trimpath -ldflags="-s -w" + +## build-linux: Build para Linux AMD64 +build-linux: + wails build -platform linux/amd64 + +## build-windows: Cross-compile para Windows +build-windows: + wails build -platform windows/amd64 + +## build-all: Linux + Windows +build-all: build-linux build-windows + +## generate: Regenerar bindings TypeScript +generate: + wails generate module + +## clean: Limpiar artefactos +clean: + rm -rf build/bin frontend/dist + +## doctor: Verificar instalación +doctor: + wails doctor + +## help: Muestra esta ayuda +help: + @grep -E '^## ' Makefile | sed 's/## //' | column -t -s ':' +MKEOF + + # --- .gitignore --- + cat >> .gitignore << 'EOF' +node_modules/ +frontend/dist/ +build/bin/ +*.exe +EOF + + # --- Resumen Wails --- + echo "" + log_ok "Proyecto Wails '$PROJECT_NAME' creado en $PROJECT_DIR" + echo "" + echo -e "${CYAN}Estructura:${NC}" + echo " $PROJECT_NAME/" + echo " ├── main.go, app.go — Backend Go" + echo " ├── go.work — Enlace a DevFactory" + echo " ├── frontend/ — React + TypeScript + Vite" + echo " │ ├── src/ — Componentes React" + echo " │ └── wailsjs/ — Bindings auto-generados" + echo " ├── Makefile — dev, build, build-all" + echo " └── wails.json — Configuración Wails" + echo "" + echo -e "${CYAN}Comandos:${NC}" + echo " make dev — Desarrollo con hot reload" + echo " make build — Compilar app desktop" + echo " make build-all — Linux + Windows" + echo " make generate — Regenerar bindings TS" + echo "" + if [[ "$HAS_FRONTEND_LIB" == true ]]; then + echo -e "${CYAN}Frontend_Library:${NC}" + echo " import { Button, Card } from '@anthropic/frontend-lib'" + echo " import { useTheme } from '@anthropic/frontend-lib/hooks'" + echo "" + fi + echo "STATUS: READY" + exit 0 +fi + +# ============================================================================ +# MODO WEBAPP — React + Vite (sin Wails) +# ============================================================================ +log_step "Creando proyecto webapp '$PROJECT_NAME'..." + +mkdir -p "$PROJECT_DIR" +cd "$PROJECT_DIR" + +# --- Usar template de Frontend_Library si existe --- +if [[ -d "$TEMPLATES_DIR" ]]; then + log_step "Usando template de Frontend_Library..." + cp -r "$TEMPLATES_DIR"/* . + cp -r "$TEMPLATES_DIR"/.[!.]* . 2>/dev/null || true +else + log_step "Creando desde cero con Vite..." + + # package.json + cat > package.json << PKGEOF +{ + "name": "$PROJECT_NAME", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + } +} +PKGEOF + + # Instalar dependencias base + pnpm add react react-dom + pnpm add -D typescript @types/react @types/react-dom + pnpm add -D vite @vitejs/plugin-react + pnpm add -D tailwindcss @tailwindcss/vite + + # tsconfig.json + cat > tsconfig.json << 'TSEOF' +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "paths": { "@/*": ["./src/*"] }, + "skipLibCheck": true + }, + "include": ["src"] +} +TSEOF + + # vite.config.ts + cat > vite.config.ts << 'VEOF' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { '@': resolve(__dirname, './src') }, + dedupe: ['react', 'react-dom'], + }, +}) +VEOF + + # index.html + cat > index.html << HTMLEOF + + + + + + $PROJECT_NAME + + +
+ + + +HTMLEOF + + # src/ + mkdir -p src + + cat > src/main.tsx << 'TSXEOF' +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './app.css' + +createRoot(document.getElementById('root')!).render( + + + , +) +TSXEOF + + cat > src/App.tsx << 'TSXEOF' +function App() { + return ( +
+

Ready

+
+ ) +} + +export default App +TSXEOF + + cat > src/app.css << 'CSSEOF' +@import "tailwindcss"; +CSSEOF +fi + +# --- Linkear Frontend_Library --- +if [[ "$HAS_FRONTEND_LIB" == true ]]; then + log_step "Linkeando Frontend_Library..." + pnpm add "@anthropic/frontend-lib@link:$FRONTEND_LIB" + log_ok "@anthropic/frontend-lib linkeada" +fi + +# --- Phosphor Icons --- +pnpm add @phosphor-icons/react + +# --- .gitignore --- +cat > .gitignore << 'EOF' +node_modules/ +dist/ +.vite/ +*.local +EOF + +# --- Resumen Webapp --- +echo "" +log_ok "Proyecto webapp '$PROJECT_NAME' creado en $PROJECT_DIR" +echo "" +echo -e "${CYAN}Estructura:${NC}" +echo " $PROJECT_NAME/" +echo " ├── src/" +echo " │ ├── App.tsx — Componente principal" +echo " │ ├── main.tsx — Entry point" +echo " │ └── app.css — Tailwind CSS" +echo " ├── vite.config.ts — Vite + Tailwind + dedupe" +echo " ├── tsconfig.json — TypeScript strict" +echo " └── package.json — pnpm" +echo "" +echo -e "${CYAN}Comandos:${NC}" +echo " pnpm dev — Servidor de desarrollo" +echo " pnpm build — Build de producción" +echo " pnpm preview — Preview del build" +echo "" +if [[ "$HAS_FRONTEND_LIB" == true ]]; then + echo -e "${CYAN}Frontend_Library:${NC}" + echo " import { Button, Card } from '@anthropic/frontend-lib'" + echo " import { useTheme } from '@anthropic/frontend-lib/hooks'" + echo "" +fi +echo "STATUS: READY" diff --git a/.claude/skills/init-go-module/SKILL.md b/.claude/skills/init-go-module/SKILL.md new file mode 100644 index 0000000..4228712 --- /dev/null +++ b/.claude/skills/init-go-module/SKILL.md @@ -0,0 +1,53 @@ +--- +name: init-go-module +description: Inicializa un módulo Go funcional con bindings Python (CGO c-shared + ctypes) +disable-model-invocation: true +user-invocable: true +allowed-tools: Bash, Read, Write, Edit +--- + +# init-go-module + +Inicializa un módulo Go con arquitectura funcional (pure core / impure shell) y bindings Python automáticos via CGO c-shared + ctypes. Coherente con DevFactory y el stack del backend-lib agent. + +## Sintaxis + +```bash +/init-go-module [nombre] [--path /ruta/destino] +``` + +- `nombre`: nombre del módulo (kebab-case). Si no se da, se pregunta. +- `--path`: directorio destino. Default: directorio actual. + +## Flujo + +### 1. Ejecutar script de setup + +```bash +bash "${CLAUDE_SKILL_DIR}/setup-go-module.sh" [nombre] [path] +``` + +### 2. Si el script reporta STATUS: CONFIGURED + +Informar al usuario que el módulo ya está configurado. + +### 3. Si el script reporta STATUS: READY + +Mostrar resumen: +- Estructura creada +- Cómo compilar: `make build` +- Cómo generar bindings Python: `make python` +- Cómo testear: `make test` +- Cómo usar desde Python: `from bindings.modulo import *` + +### 4. Si el script reporta STATUS: ERROR + +Mostrar el error y sugerir corrección. + +## Convenciones + +- Usa DevFactory como dependencia via `go.work` (igual que build-wails) +- Patrón pure core / impure shell de DevFactory +- `Result[T]` y `Option[T]` del core de DevFactory +- Funciones exportadas a Python son thin wrappers en `export/` +- El wrapper Python se auto-genera desde los `//export` comments diff --git a/.claude/skills/init-go-module/setup-go-module.sh b/.claude/skills/init-go-module/setup-go-module.sh new file mode 100755 index 0000000..f80f21b --- /dev/null +++ b/.claude/skills/init-go-module/setup-go-module.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# setup-go-module.sh — Inicializa módulo Go funcional con bindings Python +# Coherente con DevFactory (pure core / impure shell) + CGO c-shared + ctypes +# ============================================================================= + +# --- Colores --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_ok() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $1"; } + +# --- Parámetros --- +MODULE_NAME="${1:-}" +TARGET_PATH="${2:-.}" +DEVFACTORY_PATH="$HOME/.local_agentes/backend" +DEVFACTORY_MODULE="github.com/lucasdataproyects/devfactory" + +# --- Validar nombre --- +if [[ -z "$MODULE_NAME" ]]; then + log_error "Uso: setup-go-module.sh [path]" + echo "STATUS: ERROR" + exit 1 +fi + +# Normalizar nombre a kebab-case +MODULE_NAME=$(echo "$MODULE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') +PROJECT_DIR="$TARGET_PATH/$MODULE_NAME" + +# --- Check estado existente --- +if [[ -f "$PROJECT_DIR/go.mod" ]]; then + log_warn "El módulo $MODULE_NAME ya existe en $PROJECT_DIR" + if [[ -f "$PROJECT_DIR/export/exports.go" ]]; then + log_ok "Bindings Python ya configurados" + else + log_warn "Falta directorio export/ — ejecuta de nuevo para completar" + fi + echo "STATUS: CONFIGURED" + exit 0 +fi + +# --- Verificar dependencias --- +log_step "Verificando dependencias..." + +if ! command -v go &>/dev/null; then + log_error "Go no está instalado. Instala Go 1.22+" + echo "STATUS: ERROR" + exit 1 +fi + +GO_VERSION=$(go version | grep -oP '\d+\.\d+' | head -1) +log_ok "Go $GO_VERSION encontrado" + +if ! command -v python3 &>/dev/null; then + log_warn "Python3 no encontrado — los bindings se generarán pero no se podrán testear" +fi + +if [[ ! -d "$DEVFACTORY_PATH" ]]; then + log_warn "DevFactory no encontrado en $DEVFACTORY_PATH — se creará go.mod sin go.work" +fi + +# --- Crear estructura --- +log_step "Creando estructura del módulo '$MODULE_NAME'..." + +mkdir -p "$PROJECT_DIR"/{core,shell,export,python/bindings,cmd,internal} + +# --- go.mod --- +log_step "Generando go.mod..." +cat > "$PROJECT_DIR/go.mod" << EOF +module github.com/lucasdataproyects/$MODULE_NAME + +go 1.22 + +require $DEVFACTORY_MODULE v0.0.0 +EOF + +# --- go.work (si DevFactory existe localmente) --- +if [[ -d "$DEVFACTORY_PATH" ]]; then + log_step "Generando go.work con DevFactory local..." + cat > "$PROJECT_DIR/go.work" << EOF +go 1.22 + +use ( + . + $DEVFACTORY_PATH +) +EOF + log_ok "go.work enlazado a DevFactory" +fi + +# --- core/transform.go — Funciones puras de ejemplo --- +log_step "Generando core/ (funciones puras)..." +cat > "$PROJECT_DIR/core/transform.go" << 'GOEOF' +// Package core contiene funciones puras sin side effects. +// Todas las funciones son deterministas y composables. +package core + +import ( + "strings" + + "errors" + + df "github.com/lucasdataproyects/devfactory/core" +) + +// ToUpper transforma texto a mayúsculas (función pura). +func ToUpper(s string) string { + return strings.ToUpper(s) +} + +// ProcessItems aplica una transformación a cada elemento usando MapSlice de DevFactory. +func ProcessItems(items []string, transform func(string) string) []string { + return df.MapSlice(items, transform) +} + +// FilterNonEmpty filtra elementos vacíos usando FilterSlice de DevFactory. +func FilterNonEmpty(items []string) []string { + return df.FilterSlice(items, func(s string) bool { + return len(strings.TrimSpace(s)) > 0 + }) +} + +// SafeDivide retorna Result[float64] para evitar panic en división por cero. +func SafeDivide(a, b float64) df.Result[float64] { + if b == 0 { + return df.Err[float64](errors.New("division by zero")) + } + return df.Ok(a / b) +} +GOEOF + +# --- core/types.go — Tipos exportables a Python --- +cat > "$PROJECT_DIR/core/types.go" << 'GOEOF' +package core + +// DataPoint representa un punto de datos exportable a Python. +// Los campos usan tipos C-compatible para facilitar el binding. +type DataPoint struct { + Label string + Value float64 +} + +// Summary es el resultado de un procesamiento, exportable a Python. +type Summary struct { + Count int + Total float64 + Items []string +} +GOEOF + +# --- shell/io.go — Operaciones I/O con Result[T] --- +log_step "Generando shell/ (operaciones I/O)..." +cat > "$PROJECT_DIR/shell/io.go" << 'GOEOF' +// Package shell contiene operaciones con side effects, wrapeadas en Result[T]. +package shell + +import ( + df "github.com/lucasdataproyects/devfactory/core" + "github.com/lucasdataproyects/devfactory/shell" +) + +// ReadDataFile lee un archivo y retorna su contenido como Result. +func ReadDataFile(path string) df.Result[string] { + return shell.ReadString(path) +} + +// WriteResult escribe un resultado a archivo. +func WriteResult(path string, content string) df.Result[struct{}] { + return shell.WriteString(path, content) +} +GOEOF + +# --- export/exports.go — Funciones exportadas via CGO --- +log_step "Generando export/ (bindings CGO)..." +cat > "$PROJECT_DIR/export/exports.go" << GOEOF +// Package main exporta funciones Go como C shared library. +// Cada función con //export se expone como símbolo C callable desde Python. +package main + +import "C" +import ( + "encoding/json" + "unsafe" + + "github.com/lucasdataproyects/$MODULE_NAME/core" +) + +//export GoToUpper +func GoToUpper(input *C.char) *C.char { + result := core.ToUpper(C.GoString(input)) + return C.CString(result) +} + +//export GoProcessItems +func GoProcessItems(jsonInput *C.char) *C.char { + var items []string + if err := json.Unmarshal([]byte(C.GoString(jsonInput)), &items); err != nil { + return C.CString("[]") + } + result := core.ProcessItems(items, core.ToUpper) + out, _ := json.Marshal(result) + return C.CString(string(out)) +} + +//export GoFilterNonEmpty +func GoFilterNonEmpty(jsonInput *C.char) *C.char { + var items []string + if err := json.Unmarshal([]byte(C.GoString(jsonInput)), &items); err != nil { + return C.CString("[]") + } + result := core.FilterNonEmpty(items) + out, _ := json.Marshal(result) + return C.CString(string(out)) +} + +//export GoSafeDivide +func GoSafeDivide(a, b C.double) *C.char { + result := core.SafeDivide(float64(a), float64(b)) + if result.IsErr() { + return C.CString(`{"error":"` + result.Error().Error() + `"}`) + } + out, _ := json.Marshal(map[string]float64{"value": result.Unwrap()}) + return C.CString(string(out)) +} + +//export GoFree +func GoFree(ptr *C.char) { + C.free(unsafe.Pointer(ptr)) +} + +func main() {} +GOEOF + +# --- python/bindings/__init__.py — Wrapper ctypes auto-generado --- +log_step "Generando python/bindings/ (ctypes wrapper)..." + +# Nombre de la shared library según OS +SO_NAME="lib${MODULE_NAME}.so" + +cat > "$PROJECT_DIR/python/bindings/__init__.py" << PYEOF +""" +Auto-generated Python bindings for $MODULE_NAME. +Uses ctypes to call Go functions compiled as C shared library. + +Usage: + from bindings import to_upper, process_items, filter_non_empty, safe_divide +""" +import ctypes +import json +import os +from pathlib import Path + +# Localizar la shared library +_LIB_DIR = Path(__file__).parent.parent.parent / "build" +_LIB_NAME = "$SO_NAME" +_LIB_PATH = _LIB_DIR / _LIB_NAME + +if not _LIB_PATH.exists(): + raise FileNotFoundError( + f"Shared library not found at {_LIB_PATH}. " + f"Run 'make build' in the project root first." + ) + +_lib = ctypes.CDLL(str(_LIB_PATH)) + +# --- Configurar tipos de retorno --- +_lib.GoToUpper.argtypes = [ctypes.c_char_p] +_lib.GoToUpper.restype = ctypes.c_char_p + +_lib.GoProcessItems.argtypes = [ctypes.c_char_p] +_lib.GoProcessItems.restype = ctypes.c_char_p + +_lib.GoFilterNonEmpty.argtypes = [ctypes.c_char_p] +_lib.GoFilterNonEmpty.restype = ctypes.c_char_p + +_lib.GoSafeDivide.argtypes = [ctypes.c_double, ctypes.c_double] +_lib.GoSafeDivide.restype = ctypes.c_char_p + +_lib.GoFree.argtypes = [ctypes.c_char_p] +_lib.GoFree.restype = None + + +def to_upper(text: str) -> str: + """Convert text to uppercase using Go core.""" + result = _lib.GoToUpper(text.encode("utf-8")) + return result.decode("utf-8") + + +def process_items(items: list[str]) -> list[str]: + """Process items through Go pipeline (ToUpper transformation).""" + input_json = json.dumps(items).encode("utf-8") + result = _lib.GoProcessItems(input_json) + return json.loads(result.decode("utf-8")) + + +def filter_non_empty(items: list[str]) -> list[str]: + """Filter empty strings using Go core.""" + input_json = json.dumps(items).encode("utf-8") + result = _lib.GoFilterNonEmpty(input_json) + return json.loads(result.decode("utf-8")) + + +def safe_divide(a: float, b: float) -> float: + """Safe division using Go Result type. Raises ValueError on division by zero.""" + result = _lib.GoSafeDivide(ctypes.c_double(a), ctypes.c_double(b)) + data = json.loads(result.decode("utf-8")) + if "error" in data: + raise ValueError(data["error"]) + return data["value"] +PYEOF + +# --- python/example.py --- +cat > "$PROJECT_DIR/python/example.py" << PYEOF +"""Example usage of $MODULE_NAME Go bindings from Python.""" +from bindings import to_upper, process_items, filter_non_empty, safe_divide + +# String transformation +print(to_upper("hello from go")) # HELLO FROM GO + +# Batch processing via Go's MapSlice +items = ["hello", "world", "from", "go"] +print(process_items(items)) # ["HELLO", "WORLD", "FROM", "GO"] + +# Filtering via Go's FilterSlice +mixed = ["hello", "", "world", " ", "go"] +print(filter_non_empty(mixed)) # ["hello", "world", "go"] + +# Safe division with Result[T] error handling +print(safe_divide(10.0, 3.0)) # 3.333... +try: + safe_divide(10.0, 0.0) +except ValueError as e: + print(f"Caught: {e}") # Caught: division by zero +PYEOF + +# --- core/transform_test.go --- +log_step "Generando tests..." +cat > "$PROJECT_DIR/core/transform_test.go" << 'GOEOF' +package core + +import ( + "testing" +) + +func TestToUpper(t *testing.T) { + if got := ToUpper("hello"); got != "HELLO" { + t.Errorf("ToUpper(\"hello\") = %q, want %q", got, "HELLO") + } +} + +func TestFilterNonEmpty(t *testing.T) { + items := []string{"hello", "", "world", " ", "go"} + result := FilterNonEmpty(items) + if len(result) != 3 { + t.Errorf("FilterNonEmpty got %d items, want 3", len(result)) + } +} + +func TestSafeDivide(t *testing.T) { + ok := SafeDivide(10, 2) + if ok.IsErr() { + t.Error("SafeDivide(10, 2) should not error") + } + if ok.Unwrap() != 5.0 { + t.Errorf("SafeDivide(10, 2) = %f, want 5.0", ok.Unwrap()) + } + + err := SafeDivide(10, 0) + if !err.IsErr() { + t.Error("SafeDivide(10, 0) should error") + } +} +GOEOF + +# --- Makefile --- +log_step "Generando Makefile..." +cat > "$PROJECT_DIR/Makefile" << MKEOF +.PHONY: build test clean python dev + +MODULE_NAME := $MODULE_NAME +BUILD_DIR := build +SO_NAME := $SO_NAME + +## build: Compila la shared library (.so) para Python +build: + @mkdir -p \$(BUILD_DIR) + cd export && CGO_ENABLED=1 go build -buildmode=c-shared -o ../\$(BUILD_DIR)/\$(SO_NAME) . + @echo "✓ Built \$(BUILD_DIR)/\$(SO_NAME)" + +## test: Ejecuta tests de Go +test: + go test ./core/... ./shell/... -v + +## python: Compila y ejecuta ejemplo Python +python: build + cd python && python3 example.py + +## clean: Limpia artefactos +clean: + rm -rf \$(BUILD_DIR) + find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true + +## dev: Tests + build en un paso +dev: test build + @echo "✓ Ready — run 'make python' to test bindings" + +## tidy: go mod tidy +tidy: + go mod tidy + +## help: Muestra esta ayuda +help: + @grep -E '^## ' Makefile | sed 's/## //' | column -t -s ':' +MKEOF + +# --- .gitignore --- +cat > "$PROJECT_DIR/.gitignore" << 'EOF' +build/ +*.so +*.h +*.dylib +*.dll +__pycache__/ +*.pyc +.pytest_cache/ +EOF + +# --- go mod tidy --- +log_step "Ejecutando go mod tidy..." +cd "$PROJECT_DIR" +if [[ -f "go.work" ]]; then + go mod tidy 2>/dev/null || log_warn "go mod tidy falló — revisa el go.work" +else + go mod tidy 2>/dev/null || log_warn "go mod tidy falló — DevFactory no está disponible" +fi + +# --- Resumen --- +echo "" +log_ok "Módulo '$MODULE_NAME' creado en $PROJECT_DIR" +echo "" +echo -e "${CYAN}Estructura:${NC}" +echo " $MODULE_NAME/" +echo " ├── core/ — Funciones puras (sin side effects)" +echo " ├── shell/ — Operaciones I/O con Result[T]" +echo " ├── export/ — Funciones exportadas via CGO" +echo " ├── python/bindings — Wrapper ctypes auto-generado" +echo " ├── Makefile — build, test, python, clean" +echo " └── go.work — Enlace a DevFactory" +echo "" +echo -e "${CYAN}Comandos:${NC}" +echo " make test — Ejecutar tests Go" +echo " make build — Compilar shared library" +echo " make python — Testear bindings Python" +echo " make dev — Test + build en un paso" +echo "" +echo "STATUS: READY" diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a22219 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Claude Code — Skills, Agents & Tools + +Sistema de automatizacion para desarrollo de software usando Claude Code. Incluye skills (comandos invocables), agentes especializados, y herramientas Go para orquestar trabajo paralelo. + +## Estructura del repo + +``` +repo_Claude/ +├── bin/ # Binarios compilados +│ └── parallel-executor # Orquestador de ejecucion paralela +├── utils/ +│ └── parallel-executor/ # Codigo fuente Go del orquestador +│ ├── core/ # Funciones puras (parser, planner) +│ └── shell/ # I/O (worktrees, executor, logger) +├── dev/ +│ └── issues/ # Sistema local de issues +└── install.sh # Instalador +``` + +--- + +## Skills + +Skills son comandos invocables desde Claude Code con `/nombre`. Viven en `~/.claude/skills/`. + +### Configuracion y setup + +| Skill | Descripcion | Uso | +|-------|-------------|-----| +| `/primer` | Genera CLAUDE.md personalizado analizando el repo | `/primer` | +| `/init-jupyter` | Inicializa proyecto Jupyter + MCP (bash script idempotente) | `/init-jupyter [ruta]` | +| `/init-go-module` | Crea modulo Go funcional con bindings Python (CGO + ctypes) | `/init-go-module nombre` | +| `/init-frontend` | Crea proyecto React/Vite o Wails desktop | `/init-frontend nombre [--wails]` | +| `/nochanges` | Modo read-only para explorar sin modificar | `/nochanges` | +| `/create-skill` | Crea un skill nuevo | `/create-skill nombre` | +| `/create-agent` | Crea un agente especializado | `/create-agent` | + +### Git + +| Skill | Descripcion | Uso | +|-------|-------------|-----| +| `/git-branch` | Crea ramas `issue/*` o `quick/*` desde master | `/git-branch issue 0013 slug` | +| `/git-push` | Commits atomicos por tipo, merge --no-ff, push, limpieza | `/git-push` | +| `/git-recovery` | Recupera repo de estados inconsistentes | `/git-recovery [--aggressive]` | + +### Workspace (Gitea + SQLite) + +| Skill | Descripcion | Uso | +|-------|-------------|-----| +| `/create-repo` | Crea workspace en Gitea con rollback | `/create-repo` | +| `/import-repo` | Importa repo existente a Gitea (mirror) | `/import-repo` | +| `/sync-repos` | Sincroniza workspaces locales con Gitea | `/sync-repos [--dry-run]` | +| `/list-repos` | Lista workspaces desde SQLite | `/list-repos [--filter x]` | +| `/cleanup-worktrees` | Limpia worktrees post-merge | `/cleanup-worktrees [--all]` | + +### Issues + +| Skill | Descripcion | Uso | +|-------|-------------|-----| +| `/create-issue` | Crea issue con template, sub-issues si es grande | `/create-issue` | +| `/fix-issue` | E2E: lee issue, branch, implementa, tests, merge | `/fix-issue 0013` | +| `/auto-fix` | Igual que fix-issue pero sin confirmacion | `/auto-fix 0013` | +| `/auto-create` | Igual que create-issue sin confirmacion | `/auto-create` | +| `/quick-issue` | Issue minimal desde texto (para TUI) | `/quick-issue --text "..."` | +| `/issues-status` | Dashboard con filtros por workspace/estado/tag | `/issues-status` | + +### Ejecucion paralela + +| Skill | Descripcion | Uso | +|-------|-------------|-----| +| `/sort-issues` | Analiza dependencias, topological sort | `/sort-issues` | +| `/parallel-issues` | Genera plan de ejecucion paralela | `/parallel-issues` | +| `/execute-parallel` | Crea worktrees y ejecuta issues en paralelo | `/execute-parallel [--dry-run]` | + +--- + +## Agentes + +Agentes especializados que Claude Code puede invocar automaticamente. Cada uno tiene su propio modelo, herramientas y MCP servers. Viven en `~/.claude/agents/`. + +### Librerias de desarrollo + +| Agente | Modelo | Descripcion | Ubicacion | +|--------|--------|-------------|-----------| +| **backend-lib** | sonnet | Gestiona DevFactory — libreria Go funcional con Result[T], Option[T], HTTP, DB, finance | `~/.local_agentes/backend` | +| **frontend-lib** | sonnet | Gestiona Frontend_Library — 50+ componentes React/TS, temas OKLCH, shadcn/ui, charts, DSP | `~/.local_agentes/frontend` | + +### Build y deploy + +| Agente | Modelo | Descripcion | +|--------|--------|-------------| +| **build-wails** | sonnet | Apps desktop Wails v2 (Go + React). Cross-compile Linux/Windows/macOS. Integra ambas librerias | +| **docker** | sonnet | Dockerfiles multi-stage, docker-compose, push a registries, deploy via SSH | + +### Datos e infraestructura + +| Agente | Modelo | Descripcion | +|--------|--------|-------------| +| **db-reader** | sonnet | Bases de datos SQLite y DuckDB. Consultas, imports CSV/Parquet/JSON, analisis OLAP | +| **gitea** | sonnet | Gestion de Gitea: repos, issues, PRs, branches, archivos. MCP integrado | + +### Automatizacion + +| Agente | Modelo | Descripcion | +|--------|--------|-------------| +| **navegator** | sonnet | Automatizacion web con Go + Chrome DevTools Protocol (chromedp). Perfiles de navegacion | + +--- + +## Parallel Executor + +Binario Go en `utils/parallel-executor/` que orquesta la ejecucion paralela de issues usando git worktrees. + +### Arquitectura + +Sigue el patron **pure core / impure shell** de DevFactory: + +``` +utils/parallel-executor/ +├── core/ # Funciones puras, 0 side effects +│ ├── parser.go # Parsea PARALLEL_EXECUTION_ORDER.md +│ ├── planner.go # Topological sort (Kahn), deteccion de ciclos +│ ├── parser_test.go +│ └── planner_test.go +├── shell/ # Operaciones I/O con Result[T] +│ ├── worktree.go # CRUD de git worktrees +│ ├── executor.go # Invoca `claude -p` en cada worktree +│ └── logger.go # Logs por sesion e issue +├── main.go # CLI +├── go.mod + go.work # DevFactory como dependencia +└── Makefile +``` + +### Uso + +```bash +# Compilar +cd utils/parallel-executor && make build + +# Analizar issues y generar plan +./bin/parallel-executor --sort + +# Ver que haria sin ejecutar +./bin/parallel-executor --dry-run + +# Ejecutar todo +./bin/parallel-executor + +# Solo un grupo +./bin/parallel-executor --group 1 + +# Sin paralelismo +./bin/parallel-executor --sequential + +# Solo limpiar worktrees +./bin/parallel-executor --cleanup +``` + +### Flujo de ejecucion + +1. Lee (o genera) `PARALLEL_EXECUTION_ORDER.md` +2. Agrupa issues por dependencias (topological sort) +3. Por cada grupo, crea worktrees en `worktrees/issue-NNNN/` +4. Ejecuta `claude -p` en paralelo dentro de cada worktree +5. Mergea branches exitosas a master (`--no-ff`) +6. Limpia worktrees automaticamente +7. Escribe logs en `logs/` + +--- + +## Stack tecnologico + +### Backend (Go) + +- **Go 1.22+** con generics +- **DevFactory** (`~/.local_agentes/backend`): Result[T], Option[T], MapSlice, FilterSlice, Reduce, Pipe, Curry +- Patron **pure core / impure shell**: funciones puras en `core/`, I/O wrapeado en Result[T] en `shell/` +- **DuckDB** para analytics, **SQLite** para metadata +- **Bubble Tea** para TUIs + +### Frontend (React) + +- **React 19** + TypeScript strict + **Vite** + **Tailwind CSS 4** (OKLCH) +- **Frontend_Library** (`~/.local_agentes/frontend`): shadcn/ui, Phosphor Icons, 50+ componentes +- **pnpm** exclusivamente, link via `pnpm add @anthropic/frontend-lib@link:...` +- Vite dedupe obligatorio para `react`, `react-dom` +- **Storybook 10** para documentacion de componentes + +### Desktop + +- **Wails v2** (Go backend + React frontend) +- Cross-compile: Linux AMD64/ARM64, Windows AMD64 +- DevFactory via `go.work`, Frontend_Library via `pnpm link` + +### Infraestructura + +- **Gitea** self-hosted para repositorios +- **Docker** multi-stage builds +- **Trunk-based development**: ramas `issue/*` y `quick/*`, merge rapido a master + +--- + +## Convenciones + +- **Inmutabilidad**: no mutar datos, crear copias nuevas +- **Result[T]** para errores, no `(T, error)` — permite encadenamiento monadico +- **Commits atomicos**: agrupados por tipo (feat, fix, refactor, docs, chore, test) +- **Issues**: formato 3-4 digitos, estado en markdown, sub-issues con sufijo letra +- **Skills con bash scripts**: idempotentes, detectan estado existente, colores en output diff --git a/bin/parallel-executor b/bin/parallel-executor new file mode 100755 index 0000000..0a9693f Binary files /dev/null and b/bin/parallel-executor differ diff --git a/utils/parallel-executor/Makefile b/utils/parallel-executor/Makefile new file mode 100644 index 0000000..590410f --- /dev/null +++ b/utils/parallel-executor/Makefile @@ -0,0 +1,31 @@ +.PHONY: build test clean install + +BIN := parallel-executor +BUILD_DIR := ../../bin + +## build: Compila el binario +build: + @mkdir -p $(BUILD_DIR) + go build -trimpath -ldflags="-s -w" -o $(BUILD_DIR)/$(BIN) . + @echo "✓ Built $(BUILD_DIR)/$(BIN)" + +## test: Ejecuta tests +test: + go test ./core/... -v + +## clean: Elimina artefactos +clean: + rm -f $(BUILD_DIR)/$(BIN) + +## install: Copia binario a la skill +install: build + @echo "✓ Binary at $(BUILD_DIR)/$(BIN)" + @echo " Use: $(BUILD_DIR)/$(BIN) --help" + +## tidy: go mod tidy +tidy: + go mod tidy + +## help: Muestra ayuda +help: + @grep -E '^## ' Makefile | sed 's/## //' | column -t -s ':' diff --git a/utils/parallel-executor/core/parser.go b/utils/parallel-executor/core/parser.go new file mode 100644 index 0000000..53186af --- /dev/null +++ b/utils/parallel-executor/core/parser.go @@ -0,0 +1,251 @@ +// Package core contiene funciones puras para el parallel executor. +// Sin side effects: parseo de planes, análisis de dependencias, agrupamiento. +package core + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + df "github.com/lucasdataproyects/devfactory/core" +) + +// Issue representa una issue parseada del plan de ejecución. +type Issue struct { + Number int + Slug string + Title string + Group int + Deps []int +} + +// ExecutionPlan es el plan completo parseado del markdown. +type ExecutionPlan struct { + Groups []IssueGroup + Total int +} + +// IssueGroup es un conjunto de issues ejecutables en paralelo. +type IssueGroup struct { + Number int + Name string + Issues []Issue +} + +// WorktreeSpec define la especificación para crear un worktree. +type WorktreeSpec struct { + Issue Issue + BranchName string + WorkDir string +} + +// ExecutionResult es el resultado de ejecutar una issue. +type ExecutionResult struct { + Issue Issue + Success bool + Duration string + Error string + LogFile string +} + +var ( + groupHeaderRe = regexp.MustCompile(`^##\s+Grupo\s+(\d+)`) + issueLineRe = regexp.MustCompile(`-\s+Issue\s+#(\d{4})\s*-?\s*(.*)`) + depRe = regexp.MustCompile(`#(\d{4})`) +) + +// ParsePlan parsea el contenido markdown de PARALLEL_EXECUTION_ORDER.md. +// Función pura: recibe string, retorna Result[ExecutionPlan]. +func ParsePlan(content string) df.Result[ExecutionPlan] { + lines := strings.Split(content, "\n") + if len(lines) == 0 { + return df.Err[ExecutionPlan](errors.New("empty plan")) + } + + groups := parsePlanGroups(lines) + if len(groups) == 0 { + return df.Err[ExecutionPlan](errors.New("no groups found in plan")) + } + + total := df.Reduce(groups, 0, func(acc int, g IssueGroup) int { + return acc + len(g.Issues) + }) + + return df.Ok(ExecutionPlan{ + Groups: groups, + Total: total, + }) +} + +// parsePlanGroups extrae los grupos del markdown. +func parsePlanGroups(lines []string) []IssueGroup { + var groups []IssueGroup + var currentGroup *IssueGroup + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if matches := groupHeaderRe.FindStringSubmatch(trimmed); len(matches) > 1 { + if currentGroup != nil { + groups = append(groups, *currentGroup) + } + num, _ := strconv.Atoi(matches[1]) + currentGroup = &IssueGroup{ + Number: num, + Name: trimmed, + } + continue + } + + if currentGroup != nil { + if matches := issueLineRe.FindStringSubmatch(trimmed); len(matches) > 1 { + num, _ := strconv.Atoi(matches[1]) + title := strings.TrimSpace(matches[2]) + deps := extractDeps(title, num) + issue := Issue{ + Number: num, + Slug: issueSlug(num, title), + Title: title, + Group: currentGroup.Number, + Deps: deps, + } + currentGroup.Issues = append(currentGroup.Issues, issue) + } + } + } + + if currentGroup != nil && len(currentGroup.Issues) > 0 { + groups = append(groups, *currentGroup) + } + + return groups +} + +// extractDeps extrae números de issues referenciadas como dependencias. +func extractDeps(text string, selfNum int) []int { + matches := depRe.FindAllStringSubmatch(text, -1) + return df.FilterSlice( + df.MapSlice(matches, func(m []string) int { + n, _ := strconv.Atoi(m[1]) + return n + }), + func(n int) bool { return n != selfNum }, + ) +} + +// issueSlug genera el slug de branch para una issue. +func issueSlug(num int, title string) string { + slug := strings.ToLower(title) + slug = regexp.MustCompile(`[^a-z0-9\s-]`).ReplaceAllString(slug, "") + slug = regexp.MustCompile(`\s+`).ReplaceAllString(slug, "-") + slug = regexp.MustCompile(`-{2,}`).ReplaceAllString(slug, "-") + slug = strings.Trim(slug, "-") + + words := strings.SplitN(slug, "-", 5) + if len(words) > 4 { + words = words[:4] + } + slug = strings.Join(words, "-") + + if slug == "" { + slug = "issue" + } + + return formatIssueNum(num) + "-" + slug +} + +func formatIssueNum(n int) string { + return fmt.Sprintf("%04d", n) +} + +// FilterGroup filtra el plan para ejecutar solo un grupo específico. +func FilterGroup(plan ExecutionPlan, groupNum int) df.Result[ExecutionPlan] { + filtered := df.FilterSlice(plan.Groups, func(g IssueGroup) bool { + return g.Number == groupNum + }) + if len(filtered) == 0 { + return df.Err[ExecutionPlan](fmt.Errorf("group not found: %d", groupNum)) + } + total := len(filtered[0].Issues) + return df.Ok(ExecutionPlan{Groups: filtered, Total: total}) +} + +// BuildWorktreeSpecs genera las specs de worktrees para un grupo de issues. +// Función pura: recibe issues y base path, retorna specs. +func BuildWorktreeSpecs(issues []Issue, basePath string) []WorktreeSpec { + return df.MapSlice(issues, func(issue Issue) WorktreeSpec { + branch := "issue/" + issue.Slug + workDir := basePath + "/worktrees/issue-" + formatIssueNum(issue.Number) + return WorktreeSpec{ + Issue: issue, + BranchName: branch, + WorkDir: workDir, + } + }) +} + +// ParseIssueFiles parsea los archivos de issues del directorio dev/issues/. +// Retorna issues con dependencias extraídas del contenido de cada archivo. +func ParseIssueFiles(files map[string]string) []Issue { + var issues []Issue + + numRe := regexp.MustCompile(`^(\d{3,4})[a-z]?-(.+)\.md$`) + + for filename, content := range files { + matches := numRe.FindStringSubmatch(filename) + if len(matches) < 3 { + continue + } + + num, _ := strconv.Atoi(matches[1]) + slug := matches[2] + + // Extraer título de la primera línea + title := slug + for _, line := range strings.SplitN(content, "\n", 5) { + if strings.HasPrefix(line, "# ") { + title = strings.TrimPrefix(line, "# ") + title = strings.TrimSpace(title) + break + } + } + + // Extraer dependencias de "Bloqueada por" o tabla de dependencias + deps := extractAllDeps(content, num) + + // Solo incluir si está pendiente + if isIssuePending(content) { + issues = append(issues, Issue{ + Number: num, + Slug: slug, + Title: title, + Deps: deps, + }) + } + } + + return issues +} + +func extractAllDeps(content string, selfNum int) []int { + allMatches := depRe.FindAllStringSubmatch(content, -1) + seen := make(map[int]bool) + var deps []int + for _, m := range allMatches { + n, _ := strconv.Atoi(m[1]) + if n != selfNum && !seen[n] { + seen[n] = true + deps = append(deps, n) + } + } + return deps +} + +func isIssuePending(content string) bool { + lower := strings.ToLower(content) + return strings.Contains(lower, "pendiente") || + strings.Contains(lower, "🟡") || + (!strings.Contains(lower, "completado") && !strings.Contains(lower, "✅")) +} diff --git a/utils/parallel-executor/core/parser_test.go b/utils/parallel-executor/core/parser_test.go new file mode 100644 index 0000000..a47c8f8 --- /dev/null +++ b/utils/parallel-executor/core/parser_test.go @@ -0,0 +1,89 @@ +package core + +import ( + "testing" +) + +func TestParsePlan(t *testing.T) { + plan := `# Plan de Ejecución Paralela + +## Grupo 1 + +- Issue #0003 - testing agent +- Issue #0006 - improve db-reader + +## Grupo 2 + +- Issue #0004 - api client — depende de #0003 +` + + result := ParsePlan(plan) + if result.IsErr() { + t.Fatalf("ParsePlan failed: %v", result.Error()) + } + + ep := result.Unwrap() + if len(ep.Groups) != 2 { + t.Errorf("expected 2 groups, got %d", len(ep.Groups)) + } + if ep.Total != 3 { + t.Errorf("expected 3 total issues, got %d", ep.Total) + } + if ep.Groups[0].Issues[0].Number != 3 { + t.Errorf("expected issue #0003, got #%04d", ep.Groups[0].Issues[0].Number) + } + if len(ep.Groups[1].Issues[0].Deps) != 1 || ep.Groups[1].Issues[0].Deps[0] != 3 { + t.Errorf("expected issue #0004 to depend on #0003") + } +} + +func TestParsePlanEmpty(t *testing.T) { + result := ParsePlan("") + if !result.IsErr() { + t.Error("expected error for empty plan") + } +} + +func TestFilterGroup(t *testing.T) { + plan := ExecutionPlan{ + Groups: []IssueGroup{ + {Number: 1, Issues: []Issue{{Number: 1}}}, + {Number: 2, Issues: []Issue{{Number: 2}, {Number: 3}}}, + }, + Total: 3, + } + + result := FilterGroup(plan, 2) + if result.IsErr() { + t.Fatalf("FilterGroup failed: %v", result.Error()) + } + filtered := result.Unwrap() + if filtered.Total != 2 { + t.Errorf("expected 2 issues, got %d", filtered.Total) + } +} + +func TestFilterGroupNotFound(t *testing.T) { + plan := ExecutionPlan{Groups: []IssueGroup{{Number: 1}}} + result := FilterGroup(plan, 99) + if !result.IsErr() { + t.Error("expected error for non-existent group") + } +} + +func TestBuildWorktreeSpecs(t *testing.T) { + issues := []Issue{ + {Number: 3, Slug: "0003-testing"}, + {Number: 6, Slug: "0006-db-reader"}, + } + specs := BuildWorktreeSpecs(issues, "/tmp/project") + if len(specs) != 2 { + t.Fatalf("expected 2 specs, got %d", len(specs)) + } + if specs[0].BranchName != "issue/0003-testing" { + t.Errorf("expected branch issue/0003-testing, got %s", specs[0].BranchName) + } + if specs[1].WorkDir != "/tmp/project/worktrees/issue-0006" { + t.Errorf("unexpected workdir: %s", specs[1].WorkDir) + } +} diff --git a/utils/parallel-executor/core/planner.go b/utils/parallel-executor/core/planner.go new file mode 100644 index 0000000..e616f90 --- /dev/null +++ b/utils/parallel-executor/core/planner.go @@ -0,0 +1,217 @@ +package core + +import ( + "errors" + "fmt" + "sort" + + df "github.com/lucasdataproyects/devfactory/core" +) + +// TopologicalSort ordena issues por dependencias usando Kahn's algorithm. +// Función pura: retorna Result con los grupos ordenados. +func TopologicalSort(issues []Issue) df.Result[[]IssueGroup] { + if len(issues) == 0 { + return df.Err[[]IssueGroup](errors.New("no issues to sort")) + } + + // Construir mapa de issues por número + issueMap := make(map[int]Issue) + for _, issue := range issues { + issueMap[issue.Number] = issue + } + + // Calcular in-degree (solo deps que existen en el set) + inDegree := make(map[int]int) + for _, issue := range issues { + if _, exists := inDegree[issue.Number]; !exists { + inDegree[issue.Number] = 0 + } + for _, dep := range issue.Deps { + if _, exists := issueMap[dep]; exists { + inDegree[issue.Number]++ + } + } + } + + // Kahn's algorithm por niveles (cada nivel = un grupo paralelo) + var groups []IssueGroup + resolved := make(map[int]bool) + remaining := len(issues) + groupNum := 1 + + for remaining > 0 { + // Encontrar issues con in-degree 0 + var ready []Issue + for _, issue := range issues { + if !resolved[issue.Number] && inDegree[issue.Number] == 0 { + issue.Group = groupNum + ready = append(ready, issue) + } + } + + if len(ready) == 0 { + // Ciclo detectado + var cycleNums []int + for _, issue := range issues { + if !resolved[issue.Number] { + cycleNums = append(cycleNums, issue.Number) + } + } + return df.Err[[]IssueGroup]( + fmt.Errorf("circular dependency detected among issues: %v", cycleNums), + ) + } + + // Ordenar por número dentro del grupo (determinista) + sort.Slice(ready, func(i, j int) bool { + return ready[i].Number < ready[j].Number + }) + + groups = append(groups, IssueGroup{ + Number: groupNum, + Name: fmt.Sprintf("Grupo %d", groupNum), + Issues: ready, + }) + + // Marcar como resueltas y decrementar in-degree + for _, issue := range ready { + resolved[issue.Number] = true + remaining-- + // Decrementar in-degree de dependientes + for _, other := range issues { + if !resolved[other.Number] { + for _, dep := range other.Deps { + if dep == issue.Number { + inDegree[other.Number]-- + } + } + } + } + } + + groupNum++ + } + + return df.Ok(groups) +} + +// AnalyzeFileConflicts detecta issues que tocan los mismos archivos. +// Recibe un mapa issue_number -> lista de archivos mencionados. +// Retorna pares de issues en conflicto. +func AnalyzeFileConflicts(filesByIssue map[int][]string) []ConflictPair { + var conflicts []ConflictPair + nums := sortedKeys(filesByIssue) + + for i := 0; i < len(nums); i++ { + for j := i + 1; j < len(nums); j++ { + shared := intersect(filesByIssue[nums[i]], filesByIssue[nums[j]]) + if len(shared) > 0 { + conflicts = append(conflicts, ConflictPair{ + IssueA: nums[i], + IssueB: nums[j], + SharedFiles: shared, + }) + } + } + } + + return conflicts +} + +// ConflictPair representa dos issues que comparten archivos. +type ConflictPair struct { + IssueA int + IssueB int + SharedFiles []string +} + +// GeneratePlanMarkdown genera el markdown del plan de ejecución. +// Función pura: recibe grupos, retorna string. +func GeneratePlanMarkdown(groups []IssueGroup, conflicts []ConflictPair) string { + var b []byte + + b = append(b, "# Plan de Ejecución Paralela\n\n"...) + b = append(b, fmt.Sprintf("Generado automáticamente. Total: %d issues en %d grupos.\n\n", + countIssues(groups), len(groups))...) + + if len(conflicts) > 0 { + b = append(b, "## Conflictos Detectados\n\n"...) + for _, c := range conflicts { + b = append(b, fmt.Sprintf("- Issue #%04d y #%04d comparten: %v\n", + c.IssueA, c.IssueB, c.SharedFiles)...) + } + b = append(b, "\n"...) + } + + for _, group := range groups { + b = append(b, fmt.Sprintf("## Grupo %d\n\n", group.Number)...) + for _, issue := range group.Issues { + depStr := "" + if len(issue.Deps) > 0 { + depStr = fmt.Sprintf(" — depende de %s", formatDeps(issue.Deps)) + } + b = append(b, fmt.Sprintf("- Issue #%04d - %s%s\n", issue.Number, issue.Title, depStr)...) + } + b = append(b, "\n"...) + } + + b = append(b, "## Resumen\n\n"...) + b = append(b, "| Métrica | Valor |\n"...) + b = append(b, "|---------|-------|\n"...) + b = append(b, fmt.Sprintf("| Issues totales | %d |\n", countIssues(groups))...) + b = append(b, fmt.Sprintf("| Grupos paralelos | %d |\n", len(groups))...) + if len(groups) > 0 { + sequential := countIssues(groups) + parallel := len(groups) + if sequential > 0 { + saving := ((sequential - parallel) * 100) / sequential + b = append(b, fmt.Sprintf("| Ahorro estimado | %d%% |\n", saving)...) + } + } + + return string(b) +} + +func countIssues(groups []IssueGroup) int { + return df.Reduce(groups, 0, func(acc int, g IssueGroup) int { + return acc + len(g.Issues) + }) +} + +func formatDeps(deps []int) string { + strs := df.MapSlice(deps, func(n int) string { + return fmt.Sprintf("#%04d", n) + }) + s := "" + for i, str := range strs { + if i > 0 { + s += ", " + } + s += str + } + return s +} + +func sortedKeys(m map[int][]string) []int { + keys := make([]int, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Ints(keys) + return keys +} + +func intersect(a, b []string) []string { + set := make(map[string]bool) + for _, s := range a { + set[s] = true + } + var result []string + for _, s := range b { + if set[s] { + result = append(result, s) + } + } + return result +} diff --git a/utils/parallel-executor/core/planner_test.go b/utils/parallel-executor/core/planner_test.go new file mode 100644 index 0000000..839a25b --- /dev/null +++ b/utils/parallel-executor/core/planner_test.go @@ -0,0 +1,102 @@ +package core + +import ( + "strings" + "testing" +) + +func TestTopologicalSort(t *testing.T) { + issues := []Issue{ + {Number: 1, Title: "base", Deps: nil}, + {Number: 2, Title: "depends on 1", Deps: []int{1}}, + {Number: 3, Title: "independent", Deps: nil}, + {Number: 4, Title: "depends on 1 and 3", Deps: []int{1, 3}}, + } + + result := TopologicalSort(issues) + if result.IsErr() { + t.Fatalf("TopologicalSort failed: %v", result.Error()) + } + + groups := result.Unwrap() + if len(groups) < 2 { + t.Fatalf("expected at least 2 groups, got %d", len(groups)) + } + + // Group 1 should have issues 1 and 3 (no deps) + g1Nums := make(map[int]bool) + for _, issue := range groups[0].Issues { + g1Nums[issue.Number] = true + } + if !g1Nums[1] || !g1Nums[3] { + t.Errorf("group 1 should contain issues 1 and 3, got %v", groups[0].Issues) + } +} + +func TestTopologicalSortCycle(t *testing.T) { + issues := []Issue{ + {Number: 1, Title: "a", Deps: []int{2}}, + {Number: 2, Title: "b", Deps: []int{1}}, + } + + result := TopologicalSort(issues) + if !result.IsErr() { + t.Error("expected cycle detection error") + } + if !strings.Contains(result.Error().Error(), "circular") { + t.Errorf("expected circular dependency error, got: %v", result.Error()) + } +} + +func TestTopologicalSortEmpty(t *testing.T) { + result := TopologicalSort(nil) + if !result.IsErr() { + t.Error("expected error for empty issues") + } +} + +func TestAnalyzeFileConflicts(t *testing.T) { + files := map[int][]string{ + 1: {"core/types.go", "shell/http.go"}, + 2: {"core/types.go", "app/main.go"}, + 3: {"app/other.go"}, + } + + conflicts := AnalyzeFileConflicts(files) + if len(conflicts) != 1 { + t.Fatalf("expected 1 conflict, got %d", len(conflicts)) + } + if conflicts[0].IssueA != 1 || conflicts[0].IssueB != 2 { + t.Errorf("expected conflict between 1 and 2, got %d and %d", + conflicts[0].IssueA, conflicts[0].IssueB) + } + if conflicts[0].SharedFiles[0] != "core/types.go" { + t.Errorf("expected shared file core/types.go, got %s", conflicts[0].SharedFiles[0]) + } +} + +func TestGeneratePlanMarkdown(t *testing.T) { + groups := []IssueGroup{ + {Number: 1, Issues: []Issue{ + {Number: 1, Title: "base"}, + {Number: 3, Title: "other"}, + }}, + {Number: 2, Issues: []Issue{ + {Number: 2, Title: "depends", Deps: []int{1}}, + }}, + } + + md := GeneratePlanMarkdown(groups, nil) + if !strings.Contains(md, "## Grupo 1") { + t.Error("markdown should contain group headers") + } + if !strings.Contains(md, "Issue #0001") { + t.Error("markdown should contain issue references") + } + if !strings.Contains(md, "3 issues") || !strings.Contains(md, "2 grupos") { + // Check summary table + if !strings.Contains(md, "| 3 |") { + t.Error("markdown should contain correct totals") + } + } +} diff --git a/utils/parallel-executor/go.mod b/utils/parallel-executor/go.mod new file mode 100644 index 0000000..73430f0 --- /dev/null +++ b/utils/parallel-executor/go.mod @@ -0,0 +1,23 @@ +module github.com/lucasdataproyects/parallel-executor + +go 1.22.2 + +require github.com/lucasdataproyects/devfactory v0.0.0 + +require ( + github.com/apache/arrow/go/v14 v14.0.2 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/marcboeker/go-duckdb v1.6.5 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/tools v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect +) + +replace github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend diff --git a/utils/parallel-executor/go.sum b/utils/parallel-executor/go.sum new file mode 100644 index 0000000..3a29209 --- /dev/null +++ b/utils/parallel-executor/go.sum @@ -0,0 +1,45 @@ +github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw= +github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/marcboeker/go-duckdb v1.6.5 h1:XCfR1JVZxsemcSPxRQKK0R0ESfgRMHTEqh3Y+dv40SI= +github.com/marcboeker/go-duckdb v1.6.5/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= +gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/utils/parallel-executor/go.work b/utils/parallel-executor/go.work new file mode 100644 index 0000000..78fc5f3 --- /dev/null +++ b/utils/parallel-executor/go.work @@ -0,0 +1,6 @@ +go 1.22.2 + +use ( + . + /home/lucas/.local_agentes/backend +) diff --git a/utils/parallel-executor/main.go b/utils/parallel-executor/main.go new file mode 100644 index 0000000..8482c80 --- /dev/null +++ b/utils/parallel-executor/main.go @@ -0,0 +1,352 @@ +// parallel-executor — Orquestador de ejecución paralela de issues. +// +// Crea git worktrees, ejecuta claude en cada uno, y mergea los resultados. +// Usa DevFactory para Result[T], MapSlice, y operaciones I/O. +// +// Uso: +// +// parallel-executor [flags] +// --plan Plan markdown (default: PARALLEL_EXECUTION_ORDER.md) +// --group Ejecutar solo grupo N +// --sequential Sin paralelismo +// --dry-run Solo mostrar plan sin ejecutar +// --timeout Timeout por issue en minutos (default: 30) +// --cleanup Solo limpiar worktrees existentes +// --sort Analizar issues y generar plan (no ejecutar) +package main + +import ( + "flag" + "fmt" + "os" + "sync" + "time" + + dfshell "github.com/lucasdataproyects/devfactory/shell" + + pcore "github.com/lucasdataproyects/parallel-executor/core" + "github.com/lucasdataproyects/parallel-executor/shell" +) + +// CLI colors +const ( + red = "\033[0;31m" + green = "\033[0;32m" + yellow = "\033[1;33m" + blue = "\033[0;34m" + cyan = "\033[0;36m" + nc = "\033[0m" +) + +func main() { + planFile := flag.String("plan", "PARALLEL_EXECUTION_ORDER.md", "plan markdown file") + groupNum := flag.Int("group", 0, "execute only this group number") + sequential := flag.Bool("sequential", false, "disable parallel execution") + dryRun := flag.Bool("dry-run", false, "show plan without executing") + timeoutMin := flag.Int("timeout", 30, "timeout per issue in minutes") + cleanup := flag.Bool("cleanup", false, "only cleanup existing worktrees") + sortMode := flag.Bool("sort", false, "analyze issues and generate plan") + flag.Parse() + + repoRoot, err := os.Getwd() + if err != nil { + fatal("cannot get working directory: %v", err) + } + + // --- Modo cleanup --- + if *cleanup { + info("Cleaning up worktrees...") + result := shell.CleanupAllWorktrees(repoRoot) + if result.IsErr() { + fatal("cleanup failed: %v", result.Error()) + } + ok("Cleaned %d worktrees", result.Unwrap()) + return + } + + // --- Modo sort: analizar issues y generar plan --- + if *sortMode { + generatePlan(repoRoot) + return + } + + // --- Ejecutar plan --- + executePlan(repoRoot, *planFile, *groupNum, *sequential, *dryRun, *timeoutMin) +} + +func generatePlan(repoRoot string) { + issuesDir := repoRoot + "/dev/issues" + if !dfshell.DirExists(issuesDir) { + fatal("issues directory not found: %s", issuesDir) + } + + info("Reading issues from %s...", issuesDir) + filesResult := shell.ReadIssueFiles(issuesDir) + if filesResult.IsErr() { + fatal("cannot read issues: %v", filesResult.Error()) + } + + issues := pcore.ParseIssueFiles(filesResult.Unwrap()) + if len(issues) == 0 { + warn("No pending issues found") + return + } + info("Found %d pending issues", len(issues)) + + // Topological sort + groupsResult := pcore.TopologicalSort(issues) + if groupsResult.IsErr() { + fatal("dependency analysis failed: %v", groupsResult.Error()) + } + + groups := groupsResult.Unwrap() + + // Generate markdown + markdown := pcore.GeneratePlanMarkdown(groups, nil) + + outFile := repoRoot + "/PARALLEL_EXECUTION_ORDER.md" + writeResult := dfshell.WriteString(outFile, markdown) + if writeResult.IsErr() { + fatal("cannot write plan: %v", writeResult.Error()) + } + + ok("Plan generated: %s", outFile) + fmt.Printf("\n%sIssues:%s %d\n", cyan, nc, len(issues)) + fmt.Printf("%sGroups:%s %d\n", cyan, nc, len(groups)) + for _, g := range groups { + fmt.Printf(" %sGrupo %d:%s %d issues\n", blue, g.Number, nc, len(g.Issues)) + for _, issue := range g.Issues { + fmt.Printf(" - #%04d %s\n", issue.Number, issue.Title) + } + } +} + +func executePlan(repoRoot string, planFile string, groupNum int, sequential bool, dryRun bool, timeoutMin int) { + // Leer plan + planContent := dfshell.ReadString(planFile) + if planContent.IsErr() { + // Intentar generar plan automáticamente + warn("Plan not found, generating from issues...") + generatePlan(repoRoot) + planContent = dfshell.ReadString(planFile) + if planContent.IsErr() { + fatal("cannot read plan: %v", planContent.Error()) + } + } + + planResult := pcore.ParsePlan(planContent.Unwrap()) + if planResult.IsErr() { + fatal("cannot parse plan: %v", planResult.Error()) + } + + plan := planResult.Unwrap() + + // Filtrar por grupo si se especificó + if groupNum > 0 { + filtered := pcore.FilterGroup(plan, groupNum) + if filtered.IsErr() { + fatal("group filter: %v", filtered.Error()) + } + plan = filtered.Unwrap() + } + + info("Plan: %d issues in %d groups", plan.Total, len(plan.Groups)) + + // --- Dry run: solo mostrar --- + if dryRun { + for _, group := range plan.Groups { + fmt.Printf("\n%s%s%s (%d issues)\n", cyan, group.Name, nc, len(group.Issues)) + specs := pcore.BuildWorktreeSpecs(group.Issues, repoRoot) + for _, spec := range specs { + fmt.Printf(" #%04d → %s\n", spec.Issue.Number, spec.BranchName) + fmt.Printf(" %s\n", spec.WorkDir) + } + } + fmt.Printf("\n%sDry run complete. No worktrees created.%s\n", yellow, nc) + return + } + + // --- Logger --- + loggerResult := shell.NewLogger(repoRoot) + if loggerResult.IsErr() { + fatal("cannot create logger: %v", loggerResult.Error()) + } + logger := loggerResult.Unwrap() + info("Logs: %s", logger.SessionLogFile()) + + // --- Ejecutar grupos secuencialmente, issues dentro de cada grupo en paralelo --- + timeout := time.Duration(timeoutMin) * time.Minute + var allResults []pcore.ExecutionResult + + for _, group := range plan.Groups { + fmt.Printf("\n%s══════════════════════════════════════%s\n", cyan, nc) + fmt.Printf("%s %s — %d issues%s\n", cyan, group.Name, len(group.Issues), nc) + fmt.Printf("%s══════════════════════════════════════%s\n", cyan, nc) + + specs := pcore.BuildWorktreeSpecs(group.Issues, repoRoot) + + // Crear worktrees + info("Creating %d worktrees...", len(specs)) + var validSpecs []pcore.WorktreeSpec + for _, spec := range specs { + result := shell.CreateWorktree(spec, repoRoot) + if result.IsErr() { + warn("Failed to create worktree for #%04d: %v", spec.Issue.Number, result.Error()) + allResults = append(allResults, pcore.ExecutionResult{ + Issue: spec.Issue, + Success: false, + Error: result.Error().Error(), + }) + continue + } + ok("Worktree: %s → %s", spec.BranchName, result.Unwrap()) + validSpecs = append(validSpecs, spec) + } + + // Ejecutar + var groupResults []pcore.ExecutionResult + if sequential || len(validSpecs) == 1 { + groupResults = executeSequential(validSpecs, timeout, logger) + } else { + groupResults = executeParallel(validSpecs, timeout, logger) + } + + allResults = append(allResults, groupResults...) + + // Mergear las exitosas + for _, r := range groupResults { + if !r.Success { + continue + } + spec := findSpec(validSpecs, r.Issue.Number) + if spec == nil { + continue + } + info("Merging %s...", spec.BranchName) + mergeResult := shell.MergeBranchToMaster(spec.BranchName, repoRoot) + if mergeResult.IsErr() { + warn("Merge failed for %s: %v", spec.BranchName, mergeResult.Error()) + } else { + ok("Merged %s", spec.BranchName) + } + } + + // Limpiar worktrees del grupo + for _, spec := range validSpecs { + shell.RemoveWorktree(spec, repoRoot) + } + } + + // --- Resumen --- + summaryResult := logger.WriteSummary(allResults) + summaryFile := "" + if summaryResult.IsOk() { + summaryFile = summaryResult.Unwrap() + } + + fmt.Printf("\n%s══════════════════════════════════════%s\n", green, nc) + fmt.Printf("%s Execution Complete%s\n", green, nc) + fmt.Printf("%s══════════════════════════════════════%s\n\n", green, nc) + + succeeded := 0 + failed := 0 + for _, r := range allResults { + if r.Success { + succeeded++ + fmt.Printf(" %s✓%s #%04d %s (%s)\n", green, nc, r.Issue.Number, r.Issue.Title, r.Duration) + } else { + failed++ + fmt.Printf(" %s✗%s #%04d %s — %s\n", red, nc, r.Issue.Number, r.Issue.Title, r.Error) + } + } + + fmt.Printf("\n Total: %d | Succeeded: %s%d%s | Failed: %s%d%s\n", + len(allResults), green, succeeded, nc, red, failed, nc) + if summaryFile != "" { + fmt.Printf(" Summary: %s\n", summaryFile) + } + fmt.Printf(" Log: %s\n", logger.SessionLogFile()) + + // Cleanup final + shell.CleanupAllWorktrees(repoRoot) + + if failed > 0 { + os.Exit(1) + } +} + +func executeSequential(specs []pcore.WorktreeSpec, timeout time.Duration, logger *shell.Logger) []pcore.ExecutionResult { + results := make([]pcore.ExecutionResult, 0, len(specs)) + + for _, spec := range specs { + info("Executing #%04d %s...", spec.Issue.Number, spec.Issue.Title) + logger.LogIssueStart(spec.Issue) + + result := shell.ExecuteIssue(spec, timeout) + logger.LogIssueResult(result) + + if result.Success { + ok("#%04d completed in %s", spec.Issue.Number, result.Duration) + } else { + warn("#%04d failed: %s", spec.Issue.Number, result.Error) + } + + results = append(results, result) + } + + return results +} + +func executeParallel(specs []pcore.WorktreeSpec, timeout time.Duration, logger *shell.Logger) []pcore.ExecutionResult { + results := make([]pcore.ExecutionResult, len(specs)) + var wg sync.WaitGroup + + for i, spec := range specs { + wg.Add(1) + go func(idx int, s pcore.WorktreeSpec) { + defer wg.Done() + + info("[goroutine] Executing #%04d %s...", s.Issue.Number, s.Issue.Title) + logger.LogIssueStart(s.Issue) + + result := shell.ExecuteIssue(s, timeout) + logger.LogIssueResult(result) + results[idx] = result + + if result.Success { + ok("[goroutine] #%04d completed in %s", s.Issue.Number, result.Duration) + } else { + warn("[goroutine] #%04d failed: %s", s.Issue.Number, result.Error) + } + }(i, spec) + } + + wg.Wait() + return results +} + +func findSpec(specs []pcore.WorktreeSpec, issueNum int) *pcore.WorktreeSpec { + for _, s := range specs { + if s.Issue.Number == issueNum { + return &s + } + } + return nil +} + +func info(format string, args ...any) { + fmt.Printf("%s[INFO]%s %s\n", blue, nc, fmt.Sprintf(format, args...)) +} + +func ok(format string, args ...any) { + fmt.Printf("%s[OK]%s %s\n", green, nc, fmt.Sprintf(format, args...)) +} + +func warn(format string, args ...any) { + fmt.Printf("%s[WARN]%s %s\n", yellow, nc, fmt.Sprintf(format, args...)) +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", red, nc, fmt.Sprintf(format, args...)) + os.Exit(1) +} diff --git a/utils/parallel-executor/shell/executor.go b/utils/parallel-executor/shell/executor.go new file mode 100644 index 0000000..513ecba --- /dev/null +++ b/utils/parallel-executor/shell/executor.go @@ -0,0 +1,127 @@ +package shell + +import ( + "fmt" + "strings" + "time" + + "github.com/lucasdataproyects/devfactory/core" + dfshell "github.com/lucasdataproyects/devfactory/shell" + + pcore "github.com/lucasdataproyects/parallel-executor/core" +) + +const defaultTimeout = 30 * time.Minute + +// ExecuteIssue ejecuta claude para resolver una issue en un worktree. +// Invoca `claude -p` con el prompt de fix-issue dentro del worktree. +func ExecuteIssue(spec pcore.WorktreeSpec, timeout time.Duration) pcore.ExecutionResult { + start := time.Now() + + if timeout == 0 { + timeout = defaultTimeout + } + + prompt := fmt.Sprintf( + "Read the issue file dev/issues/%04d-*.md and implement all tasks. "+ + "Run tests after each change. Follow pure core / impure shell pattern. "+ + "When done, commit all changes with a descriptive message.", + spec.Issue.Number, + ) + + result := dfshell.RunWithTimeout("claude", + timeout, + "-p", prompt, + "--cwd", spec.WorkDir, + ) + + duration := time.Since(start).Round(time.Second).String() + + if result.IsErr() { + return pcore.ExecutionResult{ + Issue: spec.Issue, + Success: false, + Duration: duration, + Error: result.Error().Error(), + } + } + + cmdResult := result.Unwrap() + if !cmdResult.Success() { + return pcore.ExecutionResult{ + Issue: spec.Issue, + Success: false, + Duration: duration, + Error: cmdResult.Stderr, + } + } + + return pcore.ExecutionResult{ + Issue: spec.Issue, + Success: true, + Duration: duration, + } +} + +// PushWorktreeBranch hace push de la branch del worktree al remote. +func PushWorktreeBranch(spec pcore.WorktreeSpec) core.Result[struct{}] { + result := dfshell.Run("git", "-C", spec.WorkDir, + "push", "-u", "origin", spec.BranchName, + ) + if result.IsErr() { + return core.Err[struct{}](fmt.Errorf("push failed for %s: %w", + spec.BranchName, result.Error())) + } + return core.Ok(struct{}{}) +} + +// MergeBranchToMaster mergea una branch a master con --no-ff. +func MergeBranchToMaster(branchName string, repoRoot string) core.Result[struct{}] { + // Checkout master + result := dfshell.Run("git", "-C", repoRoot, "checkout", "master") + if result.IsErr() { + return core.Err[struct{}](result.Error()) + } + + // Merge --no-ff + message := fmt.Sprintf("merge: %s — parallel execution", branchName) + result = dfshell.Run("git", "-C", repoRoot, + "merge", "--no-ff", "-m", message, branchName, + ) + if result.IsErr() { + return core.Err[struct{}](fmt.Errorf("merge failed for %s: %w", + branchName, result.Error())) + } + + return core.Ok(struct{}{}) +} + +// DeleteBranch elimina una branch local. +func DeleteBranch(branchName string, repoRoot string) core.Result[struct{}] { + result := dfshell.Run("git", "-C", repoRoot, "branch", "-d", branchName) + if result.IsErr() { + return core.Err[struct{}](result.Error()) + } + return core.Ok(struct{}{}) +} + +// ReadIssueFiles lee todos los archivos de issues de un directorio. +func ReadIssueFiles(issuesDir string) core.Result[map[string]string] { + entries := dfshell.ListDir(issuesDir) + if entries.IsErr() { + return core.Err[map[string]string](entries.Error()) + } + + files := make(map[string]string) + for _, entry := range entries.Unwrap() { + if !strings.HasSuffix(entry, ".md") || entry == "README.md" { + continue + } + content := dfshell.ReadString(issuesDir + "/" + entry) + if content.IsOk() { + files[entry] = content.Unwrap() + } + } + + return core.Ok(files) +} diff --git a/utils/parallel-executor/shell/logger.go b/utils/parallel-executor/shell/logger.go new file mode 100644 index 0000000..fd7c1e8 --- /dev/null +++ b/utils/parallel-executor/shell/logger.go @@ -0,0 +1,135 @@ +package shell + +import ( + "fmt" + "strings" + "time" + + "github.com/lucasdataproyects/devfactory/core" + dfshell "github.com/lucasdataproyects/devfactory/shell" + + pcore "github.com/lucasdataproyects/parallel-executor/core" +) + +// Logger escribe logs de la ejecución paralela a disco. +type Logger struct { + logsDir string + sessionID string +} + +// NewLogger crea un logger para la sesión actual. +func NewLogger(repoRoot string) core.Result[*Logger] { + sessionID := time.Now().Format("20060102-150405") + logsDir := repoRoot + "/logs" + + result := dfshell.MkdirAll(logsDir) + if result.IsErr() { + return core.Err[*Logger](result.Error()) + } + + return core.Ok(&Logger{ + logsDir: logsDir, + sessionID: sessionID, + }) +} + +// LogIssueStart registra el inicio de ejecución de una issue. +func (l *Logger) LogIssueStart(issue pcore.Issue) { + msg := fmt.Sprintf("[%s] START Issue #%04d - %s\n", + time.Now().Format("15:04:05"), issue.Number, issue.Title) + l.appendToSession(msg) +} + +// LogIssueResult registra el resultado de una issue. +func (l *Logger) LogIssueResult(result pcore.ExecutionResult) { + status := "SUCCESS" + if !result.Success { + status = "FAILED" + } + + msg := fmt.Sprintf("[%s] %s Issue #%04d - %s (duration: %s)", + time.Now().Format("15:04:05"), status, + result.Issue.Number, result.Issue.Title, result.Duration) + + if result.Error != "" { + msg += "\n Error: " + result.Error + } + msg += "\n" + + l.appendToSession(msg) + + // Log individual por issue + issueLogFile := fmt.Sprintf("%s/issue-%04d-%s.log", + l.logsDir, result.Issue.Number, l.sessionID) + content := fmt.Sprintf("Issue #%04d - %s\nStatus: %s\nDuration: %s\n", + result.Issue.Number, result.Issue.Title, status, result.Duration) + if result.Error != "" { + content += "Error:\n" + result.Error + "\n" + } + dfshell.WriteString(issueLogFile, content) +} + +// WriteSummary escribe el resumen consolidado. +func (l *Logger) WriteSummary(results []pcore.ExecutionResult) core.Result[string] { + summaryFile := fmt.Sprintf("%s/summary-%s.txt", l.logsDir, l.sessionID) + + var b strings.Builder + b.WriteString("=" + strings.Repeat("=", 59) + "\n") + b.WriteString(fmt.Sprintf(" Parallel Execution Summary — %s\n", l.sessionID)) + b.WriteString("=" + strings.Repeat("=", 59) + "\n\n") + + succeeded := 0 + failed := 0 + for _, r := range results { + if r.Success { + succeeded++ + } else { + failed++ + } + } + + b.WriteString(fmt.Sprintf("Total: %d\n", len(results))) + b.WriteString(fmt.Sprintf("Succeeded: %d\n", succeeded)) + b.WriteString(fmt.Sprintf("Failed: %d\n\n", failed)) + + b.WriteString("Results:\n") + b.WriteString(strings.Repeat("-", 60) + "\n") + + for _, r := range results { + status := "✓" + if !r.Success { + status = "✗" + } + b.WriteString(fmt.Sprintf(" %s #%04d %-30s %s\n", + status, r.Issue.Number, r.Issue.Title, r.Duration)) + if r.Error != "" { + b.WriteString(fmt.Sprintf(" Error: %s\n", truncate(r.Error, 80))) + } + } + + b.WriteString(strings.Repeat("-", 60) + "\n") + + writeResult := dfshell.WriteString(summaryFile, b.String()) + if writeResult.IsErr() { + return core.Err[string](writeResult.Error()) + } + + return core.Ok(summaryFile) +} + +// SessionLogFile retorna la ruta del log de sesión. +func (l *Logger) SessionLogFile() string { + return fmt.Sprintf("%s/parallel-execution-%s.log", l.logsDir, l.sessionID) +} + +func (l *Logger) appendToSession(msg string) { + logFile := l.SessionLogFile() + dfshell.AppendFile(logFile, []byte(msg)) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/utils/parallel-executor/shell/worktree.go b/utils/parallel-executor/shell/worktree.go new file mode 100644 index 0000000..621bc5c --- /dev/null +++ b/utils/parallel-executor/shell/worktree.go @@ -0,0 +1,134 @@ +// Package shell contiene operaciones I/O del parallel executor. +// Todas las funciones retornan Result[T] y tienen side effects (git, filesystem). +package shell + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/lucasdataproyects/devfactory/core" + dfshell "github.com/lucasdataproyects/devfactory/shell" + + pcore "github.com/lucasdataproyects/parallel-executor/core" +) + +// CreateWorktree crea un git worktree para una issue. +// Crea branch desde master y configura el directorio de trabajo. +func CreateWorktree(spec pcore.WorktreeSpec, repoRoot string) core.Result[string] { + // Verificar que no existe ya + if dfshell.DirExists(spec.WorkDir) { + return core.Ok(spec.WorkDir) + } + + // Crear directorio padre si no existe + parentDir := spec.WorkDir[:strings.LastIndex(spec.WorkDir, "/")] + mkResult := dfshell.MkdirAll(parentDir) + if mkResult.IsErr() { + return core.Err[string](mkResult.Error()) + } + + // Actualizar master primero + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + updateResult := dfshell.RunWithContext(ctx, "git", "-C", repoRoot, "fetch", "origin", "master") + if updateResult.IsErr() { + // No fatal — puede no tener remote + } + + // Crear worktree con nueva branch desde master + result := dfshell.Run("git", "-C", repoRoot, + "worktree", "add", + "-b", spec.BranchName, + spec.WorkDir, + "master", + ) + if result.IsErr() { + // Branch puede existir — intentar sin -b + result = dfshell.Run("git", "-C", repoRoot, + "worktree", "add", + spec.WorkDir, + spec.BranchName, + ) + if result.IsErr() { + return core.Err[string](fmt.Errorf("failed to create worktree for issue #%04d: %w", + spec.Issue.Number, result.Error())) + } + } + + return core.Ok(spec.WorkDir) +} + +// RemoveWorktree elimina un worktree y su branch. +func RemoveWorktree(spec pcore.WorktreeSpec, repoRoot string) core.Result[struct{}] { + if !dfshell.DirExists(spec.WorkDir) { + return core.Ok(struct{}{}) + } + + // Remover worktree + result := dfshell.Run("git", "-C", repoRoot, + "worktree", "remove", "--force", spec.WorkDir, + ) + if result.IsErr() { + // Fallback: eliminar directorio manualmente + os.RemoveAll(spec.WorkDir) + // Prune worktrees huérfanos + dfshell.Run("git", "-C", repoRoot, "worktree", "prune") + } + + return core.Ok(struct{}{}) +} + +// CleanupAllWorktrees limpia todos los worktrees del directorio worktrees/. +func CleanupAllWorktrees(repoRoot string) core.Result[int] { + worktreesDir := repoRoot + "/worktrees" + if !dfshell.DirExists(worktreesDir) { + return core.Ok(0) + } + + entries := dfshell.ListDir(worktreesDir) + if entries.IsErr() { + return core.Ok(0) + } + + count := 0 + for _, entry := range entries.Unwrap() { + fullPath := worktreesDir + "/" + entry + dfshell.Run("git", "-C", repoRoot, "worktree", "remove", "--force", fullPath) + count++ + } + + // Prune + dfshell.Run("git", "-C", repoRoot, "worktree", "prune") + + // Eliminar directorio vacío + os.Remove(worktreesDir) + + return core.Ok(count) +} + +// ListWorktrees devuelve los worktrees activos. +func ListWorktrees(repoRoot string) core.Result[[]string] { + result := dfshell.Run("git", "-C", repoRoot, "worktree", "list", "--porcelain") + if result.IsErr() { + return core.Err[[]string](result.Error()) + } + + output := result.Unwrap().Output() + lines := strings.Split(output, "\n") + + var paths []string + for _, line := range lines { + if strings.HasPrefix(line, "worktree ") { + path := strings.TrimPrefix(line, "worktree ") + if path != repoRoot { + paths = append(paths, path) + } + } + } + + return core.Ok(paths) +} +