Files
repo_Claude/.claude/skills/create-tui/setup-create-tui.sh
T
egutierrez c36aa18c67 feat: añadir skills create-tui, init-frontend, init-go-module y utilidades
Nuevas skills para crear TUIs, inicializar frontends React y módulos Go.
Incluye binario parallel-executor y utilidades de soporte.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:15:34 +01:00

1482 lines
37 KiB
Bash
Executable File

#!/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 <nombre> [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"