diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index ab24c55..d5a957d 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -162,6 +162,8 @@ stl builds create --branch `, &devCommand, + &lintCommand, + { Name: "@manpages", Usage: "Generate documentation for 'man'", diff --git a/pkg/cmd/configwatcher.go b/pkg/cmd/configwatcher.go new file mode 100644 index 0000000..0ce8710 --- /dev/null +++ b/pkg/cmd/configwatcher.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/urfave/cli/v3" +) + +type configChangedEvent struct{} + +func waitTillConfigChanges(ctx context.Context, cmd *cli.Command, cc *apiCommandContext) error { + openapiSpecPath := cc.workspaceConfig.OpenAPISpec + if cmd.IsSet("openapi-spec") { + openapiSpecPath = cmd.String("openapi-spec") + } + stainlessConfigPath := cc.workspaceConfig.StainlessConfig + if cmd.IsSet("stainless-config") { + stainlessConfigPath = cmd.String("stainless-config") + } + + // Get initial file modification times + openapiSpecInfo, err := os.Stat(openapiSpecPath) + if err != nil { + return fmt.Errorf("failed to get file info for %s: %v", openapiSpecPath, err) + } + openapiSpecModTime := openapiSpecInfo.ModTime() + + stainlessConfigInfo, err := os.Stat(stainlessConfigPath) + if err != nil { + return fmt.Errorf("failed to get file info for %s: %v", stainlessConfigPath, err) + } + stainlessConfigModTime := stainlessConfigInfo.ModTime() + + // Poll for file changes every 250ms + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // Check OpenAPI spec file + if info, err := os.Stat(openapiSpecPath); err == nil { + if info.ModTime().After(openapiSpecModTime) { + return nil + } + } + + // Check Stainless config file + if info, err := os.Stat(stainlessConfigPath); err == nil { + if info.ModTime().After(stainlessConfigModTime) { + return nil + } + } + + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/pkg/cmd/dev.go b/pkg/cmd/dev.go index 0296979..9e2d4ed 100644 --- a/pkg/cmd/dev.go +++ b/pkg/cmd/dev.go @@ -4,16 +4,21 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" + "os" "os/exec" "strings" "time" - "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/stainless-api/stainless-api-go" "github.com/stainless-api/stainless-api-go/option" + "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) @@ -26,6 +31,7 @@ type BuildModel struct { ended *time.Time build *stainless.Build branch string + help help.Model diagnostics []stainless.BuildDiagnostic downloads map[stainless.Target]struct { status string @@ -44,7 +50,7 @@ type fetchBuildMsg *stainless.Build type fetchDiagnosticsMsg []stainless.BuildDiagnostic type errorMsg error type downloadMsg stainless.Target -type triggerNewBuildMsg struct{} +type fileChangeMsg struct{} func NewBuildModel(cc *apiCommandContext, ctx context.Context, branch string, fn func() (*stainless.Build, error)) BuildModel { return BuildModel{ @@ -53,6 +59,7 @@ func NewBuildModel(cc *apiCommandContext, ctx context.Context, branch string, fn cc: cc, ctx: ctx, branch: branch, + help: help.New(), } } @@ -74,13 +81,17 @@ func (m BuildModel) Init() tea.Cmd { func (m BuildModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := []tea.Cmd{} switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.help.Width = msg.Width case tea.KeyMsg: switch msg.String() { case "ctrl+c": m.err = ErrUserCancelled cmds = append(cmds, tea.Quit) case "enter": - cmds = append(cmds, tea.Quit) + if m.cc.cmd.Bool("watch") { + cmds = append(cmds, tea.Quit) + } } case downloadMsg: @@ -158,10 +169,36 @@ func (m BuildModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case errorMsg: m.err = msg cmds = append(cmds, tea.Quit) + + case fileChangeMsg: + // File change detected, exit with success + cmds = append(cmds, tea.Quit) } return m, tea.Sequence(cmds...) } +func (m BuildModel) ShortHelp() []key.Binding { + if m.cc.cmd.Bool("watch") { + return []key.Binding{ + key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "rebuild")), + } + } else { + return []key.Binding{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))} + } +} + +func (m BuildModel) FullHelp() [][]key.Binding { + if m.cc.cmd.Bool("watch") { + return [][]key.Binding{{ + key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "rebuild")), + }} + } else { + return [][]key.Binding{{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))}} + } +} + func (m BuildModel) downloadTarget(target stainless.Target) tea.Cmd { return func() tea.Msg { if m.build == nil { @@ -238,8 +275,9 @@ func (m *BuildModel) getBuildDuration() time.Duration { } var devCommand = cli.Command{ - Name: "dev", - Usage: "Development mode with interactive build monitoring", + Name: "preview", + Aliases: []string{"dev"}, + Usage: "Development mode with interactive build monitoring", Flags: []cli.Flag{ &cli.StringFlag{ Name: "project", @@ -256,13 +294,33 @@ var devCommand = cli.Command{ Aliases: []string{"config"}, Usage: "Path to Stainless config file", }, + &cli.StringFlag{ + Name: "branch", + Aliases: []string{"b"}, + Usage: "Which branch to use", + }, + &cli.StringSliceFlag{ + Name: "target", + Aliases: []string{"t"}, + Usage: "The target build language(s)", + }, + &cli.BoolFlag{ + Name: "watch", + Aliases: []string{"w"}, + Value: false, + Usage: "Run in 'watch' mode to loop and rebuild when files change.", + }, }, - Action: func(ctx context.Context, cmd *cli.Command) error { - return runDevMode(ctx, cmd) - }, + Action: runPreview, } -func runDevMode(ctx context.Context, cmd *cli.Command) error { +func runPreview(ctx context.Context, cmd *cli.Command) error { + if cmd.Bool("watch") { + // Clear the screen and move the cursor to the top + fmt.Print("\033[2J\033[H") + os.Stdout.Sync() + } + cc := getAPICommandContext(cmd) gitUser, err := getGitUsername() @@ -272,7 +330,68 @@ func runDevMode(ctx context.Context, cmd *cli.Command) error { } var selectedBranch string + if cmd.IsSet("branch") { + selectedBranch = cmd.String("branch") + } else { + selectedBranch, err = chooseBranch(gitUser) + if err != nil { + return err + } + } + Property("branch", selectedBranch) + + // Phase 2: Language selection + var selectedTargets []string + targetInfos := getAvailableTargetInfo(ctx, cc.client, cmd.String("project"), cc.workspaceConfig) + if cmd.IsSet("target") { + selectedTargets = cmd.StringSlice("target") + for _, target := range selectedTargets { + if !isValidTarget(targetInfos, target) { + return fmt.Errorf("invalid language target: %s", target) + } + } + } else { + selectedTargets, err = chooseSelectedTargets(targetInfos) + } + if len(selectedTargets) == 0 { + return fmt.Errorf("no languages selected") + } + + Property("targets", strings.Join(selectedTargets, ", ")) + + // Convert string targets to stainless.Target + targets := make([]stainless.Target, len(selectedTargets)) + for i, target := range selectedTargets { + targets[i] = stainless.Target(target) + } + + // Phase 3: Start build and monitor progress in a loop + for { + // Make the user get past linter errors + if err := runLinter(ctx, cmd, true); err != nil { + if errors.Is(err, ErrUserCancelled) { + return nil + } + return err + } + + // Start the build process + if err := runDevBuild(ctx, cc, cmd, selectedBranch, targets); err != nil { + if errors.Is(err, ErrUserCancelled) { + return nil + } + return err + } + + if !cmd.Bool("watch") { + break + } + } + return nil +} + +func chooseBranch(gitUser string) (string, error) { now := time.Now() randomBytes := make([]byte, 3) rand.Read(randomBytes) @@ -290,6 +409,7 @@ func runDevMode(ctx context.Context, cmd *cli.Command) error { huh.NewOption(fmt.Sprintf("%s/", gitUser), randomBranch), ) + var selectedBranch string branchForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). @@ -301,20 +421,16 @@ func runDevMode(ctx context.Context, cmd *cli.Command) error { ).WithTheme(GetFormTheme(0)) if err := branchForm.Run(); err != nil { - return fmt.Errorf("branch selection failed: %v", err) + return selectedBranch, fmt.Errorf("branch selection failed: %v", err) } - Property("branch", selectedBranch) - - // Phase 2: Language selection - var selectedTargets []string - - // Use cached workspace config for intelligent defaults - config := cc.workspaceConfig + return selectedBranch, nil +} - targetInfo := getAvailableTargetInfo(ctx, cc.client, cmd.String("project"), config) - targetOptions := targetInfoToOptions(targetInfo) +func chooseSelectedTargets(targetInfos []TargetInfo) ([]string, error) { + targetOptions := targetInfoToOptions(targetInfos) + var selectedTargets []string targetForm := huh.NewForm( huh.NewGroup( huh.NewMultiSelect[string](). @@ -326,31 +442,9 @@ func runDevMode(ctx context.Context, cmd *cli.Command) error { ).WithTheme(GetFormTheme(0)) if err := targetForm.Run(); err != nil { - return fmt.Errorf("target selection failed: %v", err) - } - - if len(selectedTargets) == 0 { - return fmt.Errorf("no languages selected") - } - - Property("targets", strings.Join(selectedTargets, ", ")) - - // Convert string targets to stainless.Target - targets := make([]stainless.Target, len(selectedTargets)) - for i, target := range selectedTargets { - targets[i] = stainless.Target(target) - } - - // Phase 3: Start build and monitor progress in a loop - for { - err := runDevBuild(ctx, cc, cmd, selectedBranch, targets) - if err != nil { - if errors.Is(err, ErrUserCancelled) { - return nil - } - return err - } + return nil, fmt.Errorf("target selection failed: %v", err) } + return selectedTargets, nil } func runDevBuild(ctx context.Context, cc *apiCommandContext, cmd *cli.Command, branch string, languages []stainless.Target) error { @@ -420,3 +514,72 @@ func getCurrentGitBranch() (string, error) { return branch, nil } + +type GenerateSpecParams struct { + Project string `json:"project"` + Source struct { + Type string `json:"type"` + OpenAPISpec string `json:"openapi_spec"` + StainlessConfig string `json:"stainless_config"` + } `json:"source"` +} + +func getDiagnostics(ctx context.Context, cmd *cli.Command, cc *apiCommandContext) ([]stainless.BuildDiagnostic, error) { + var specParams GenerateSpecParams + if cmd.IsSet("project") { + specParams.Project = cmd.String("project") + } else { + specParams.Project = cc.workspaceConfig.Project + } + specParams.Source.Type = "upload" + + configPath := cc.workspaceConfig.StainlessConfig + if cmd.IsSet("stainless-config") { + configPath = cmd.String("stainless-config") + } + + stainlessConfig, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + specParams.Source.StainlessConfig = string(stainlessConfig) + + oasPath := cc.workspaceConfig.OpenAPISpec + if cmd.IsSet("openapi-spec") { + oasPath = cmd.String("openapi-spec") + } + + openAPISpec, err := os.ReadFile(oasPath) + if err != nil { + return nil, err + } + specParams.Source.OpenAPISpec = string(openAPISpec) + + var result []byte + err = cc.client.Post(ctx, "api/generate/spec", specParams, &result, option.WithMiddleware(cc.AsMiddleware())) + if err != nil { + return nil, err + } + + transform := "spec.diagnostics.@values.@flatten.#(ignored==false)#" + jsonObj := gjson.Parse(string(result)).Get(transform) + var diagnostics []stainless.BuildDiagnostic + json.Unmarshal([]byte(jsonObj.Raw), &diagnostics) + return diagnostics, nil +} + +func hasBlockingDiagnostic(diagnostics []stainless.BuildDiagnostic) bool { + for _, d := range diagnostics { + if !d.Ignored { + switch d.Level { + case stainless.BuildDiagnosticLevelFatal: + case stainless.BuildDiagnosticLevelError: + case stainless.BuildDiagnosticLevelWarning: + return true + case stainless.BuildDiagnosticLevelNote: + continue + } + } + } + return false +} diff --git a/pkg/cmd/dev_view.go b/pkg/cmd/dev_view.go index 7a31253..3075d1d 100644 --- a/pkg/cmd/dev_view.go +++ b/pkg/cmd/dev_view.go @@ -2,12 +2,14 @@ package cmd import ( "fmt" + "os" "strings" "time" - "github.com/charmbracelet/bubbletea" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/term" "github.com/stainless-api/stainless-api-go" ) @@ -96,9 +98,9 @@ var parts = []struct { view: func(m BuildModel, s *strings.Builder) { buildIDStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("6")).Bold(true) if m.build != nil { - s.WriteString(fmt.Sprintf("\n\n%s %s\n\n", buildIDStyle.Render(" BUILD "), m.build.ID)) + fmt.Fprintf(s, "\n\n%s %s\n\n", buildIDStyle.Render(" BUILD "), m.build.ID) } else { - s.WriteString(fmt.Sprintf("\n\n%s\n\n", buildIDStyle.Render(" BUILD "))) + fmt.Fprintf(s, "\n\n%s\n\n", buildIDStyle.Render(" BUILD ")) } }, }, @@ -108,7 +110,7 @@ var parts = []struct { if m.diagnostics == nil { s.WriteString(SProperty(0, "diagnostics", "waiting for build to finish")) } else { - s.WriteString(ViewDiagnosticsPrint(m.diagnostics)) + s.WriteString(ViewDiagnosticsPrint(m.diagnostics, 10)) } }, }, @@ -167,8 +169,7 @@ var parts = []struct { { name: "help", view: func(m BuildModel, s *strings.Builder) { - s.WriteString("\n") - s.WriteString(ViewHelpMenu()) + s.WriteString(m.help.View(m)) }, }, } @@ -243,60 +244,29 @@ func ViewStepSymbol(status, conclusion string) string { func ViewDiagnosticIcon(level stainless.BuildDiagnosticLevel) string { switch level { case stainless.BuildDiagnosticLevelFatal: - return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true).Render("💀") + return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true).Render("(F)") case stainless.BuildDiagnosticLevelError: - return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("❌") + return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("(E)") case stainless.BuildDiagnosticLevelWarning: - return lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("⚠️") + return lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("(W)") case stainless.BuildDiagnosticLevelNote: - return lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Render("ℹ️") + return lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Render("(i)") default: return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("•") } } -// ViewHelpMenu creates a styled help menu inspired by huh help component -func ViewHelpMenu() string { - keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ - Light: "#909090", - Dark: "#626262", - }) - - descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ - Light: "#B2B2B2", - Dark: "#4A4A4A", - }) - - sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ - Light: "#DDDADA", - Dark: "#3C3C3C", - }) - - helpItems := []struct { - key string - desc string - }{ - {"enter", "rebuild"}, - {"ctrl+c", "exit"}, - } - - var parts []string - for _, item := range helpItems { - parts = append(parts, - keyStyle.Render(item.key)+ - sepStyle.Render(" ")+ - descStyle.Render(item.desc)) - } - - return strings.Join(parts, sepStyle.Render(" • ")) -} - // renderMarkdown renders markdown content using glamour func renderMarkdown(content string) string { + width, _, err := term.GetSize(uintptr(os.Stdout.Fd())) + if err != nil || width <= 0 || width > 120 { + width = 120 + } r, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), - glamour.WithWordWrap(120), + glamour.WithWordWrap(width), ) + if err != nil { return content } @@ -325,7 +295,7 @@ func countDiagnosticsBySeverity(diagnostics []stainless.BuildDiagnostic) (fatal, return } -func ViewDiagnosticsPrint(diagnostics []stainless.BuildDiagnostic) string { +func ViewDiagnosticsPrint(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int) string { var s strings.Builder if len(diagnostics) > 0 { @@ -353,14 +323,13 @@ func ViewDiagnosticsPrint(diagnostics []stainless.BuildDiagnostic) string { } var sub strings.Builder - maxDiagnostics := 10 - if len(diagnostics) > maxDiagnostics { + if maxDiagnostics >= 0 && len(diagnostics) > maxDiagnostics { sub.WriteString(fmt.Sprintf("Showing first %d of %d diagnostics:\n", maxDiagnostics, len(diagnostics))) } for i, diag := range diagnostics { - if i >= maxDiagnostics { + if maxDiagnostics >= 0 && i >= maxDiagnostics { break } @@ -395,12 +364,11 @@ func ViewDiagnosticsPrint(diagnostics []stainless.BuildDiagnostic) string { s.WriteString(SProperty(0, "diagnostics", summary)) s.WriteString(lipgloss.NewStyle(). - Padding(1). + Padding(0). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("7")). + BorderForeground(lipgloss.Color("208")). Render(strings.TrimRight(sub.String(), "\n")), ) - s.WriteString("\n\n") } else { s.WriteString(SProperty(0, "diagnostics", "(no errors or warnings)")) } diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index e7287e2..cd858ca 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -523,6 +523,15 @@ func getAllTargetInfo() []TargetInfo { } } +func isValidTarget(targetInfos []TargetInfo, name string) bool { + for _, info := range targetInfos { + if info.Name == name { + return true + } + } + return false +} + // targetInfoToOptions converts TargetInfo slice to huh.Options func targetInfoToOptions(targets []TargetInfo) []huh.Option[string] { options := make([]huh.Option[string], len(targets)) diff --git a/pkg/cmd/lint.go b/pkg/cmd/lint.go new file mode 100644 index 0000000..60bb5c7 --- /dev/null +++ b/pkg/cmd/lint.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/stainless-api/stainless-api-go" + "github.com/urfave/cli/v3" +) + +var lintCommand = cli.Command{ + Name: "lint", + Usage: "Lint your stainless configuration", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "project", + Aliases: []string{"p"}, + Usage: "Project name to use for the build", + }, + &cli.StringFlag{ + Name: "openapi-spec", + Aliases: []string{"oas"}, + Usage: "Path to OpenAPI spec file", + }, + &cli.StringFlag{ + Name: "stainless-config", + Aliases: []string{"config"}, + Usage: "Path to Stainless config file", + }, + &cli.BoolFlag{ + Name: "watch", + Aliases: []string{"w"}, + Usage: "Watch for files to change and re-run linting", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Bool("watch") { + // Clear the screen and move the cursor to the top + fmt.Print("\033[2J\033[H") + os.Stdout.Sync() + } + return runLinter(ctx, cmd, false) + }, +} + +type lintModel struct { + spinner spinner.Model + diagnostics []stainless.BuildDiagnostic + error error + watching bool + canSkip bool + ctx context.Context + cmd *cli.Command + cc *apiCommandContext + stopPolling chan struct{} + help help.Model +} + +func (m lintModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m lintModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.watching = false + m.error = ErrUserCancelled + return m, tea.Quit + } else if msg.String() == "enter" { + m.watching = false + return m, tea.Quit + } + + case diagnosticsMsg: + m.diagnostics = msg.diagnostics + m.error = msg.err + m.ctx = msg.ctx + m.cmd = msg.cmd + m.cc = msg.cc + + if m.canSkip && !hasBlockingDiagnostic(m.diagnostics) { + m.watching = false + return m, tea.Quit + } + + if m.watching { + return m, func() tea.Msg { + if err := waitTillConfigChanges(m.ctx, m.cmd, m.cc); err != nil { + log.Fatal(err) + } + return configChangedEvent{} + } + } + return m, tea.Quit + + case configChangedEvent: + m.diagnostics = nil // Clear diagnostics while linting + return m, getDiagnosticsCmd(m.ctx, m.cmd, m.cc) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case tea.WindowSizeMsg: + m.help.Width = msg.Width + return m, nil + } + + return m, nil +} + +func (m lintModel) View() string { + if m.error != nil { + return fmt.Sprintf("Error: %s\n", m.error) + } + + var content string + if m.diagnostics == nil { + content = m.spinner.View() + " Linting" + } else { + content = ViewDiagnosticsPrint(m.diagnostics, -1) + if m.watching { + content += "\n" + m.spinner.View() + " Waiting for configuration changes" + } + } + + content += "\n" + m.help.View(m) + return content +} + +type diagnosticsMsg struct { + diagnostics []stainless.BuildDiagnostic + err error + ctx context.Context + cmd *cli.Command + cc *apiCommandContext +} + +func getDiagnosticsCmd(ctx context.Context, cmd *cli.Command, cc *apiCommandContext) tea.Cmd { + return func() tea.Msg { + diagnostics, err := getDiagnostics(ctx, cmd, cc) + return diagnosticsMsg{ + diagnostics: diagnostics, + err: err, + ctx: ctx, + cmd: cmd, + cc: cc, + } + } +} + +func (m lintModel) ShortHelp() []key.Binding { + if m.canSkip { + return []key.Binding{ + key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "skip diagnostics")), + } + } else { + return []key.Binding{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))} + } +} + +func (m lintModel) FullHelp() [][]key.Binding { + if m.canSkip { + return [][]key.Binding{{ + key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "skip diagnostics")), + }} + } else { + return [][]key.Binding{{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))}} + } +} + +func runLinter(ctx context.Context, cmd *cli.Command, canSkip bool) error { + cc := getAPICommandContext(cmd) + + s := spinner.New() + s.Spinner = spinner.MiniDot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("208")) + + m := lintModel{ + spinner: s, + watching: cmd.Bool("watch"), + canSkip: canSkip, + ctx: ctx, + cmd: cmd, + cc: cc, + stopPolling: make(chan struct{}), + help: help.New(), + } + + p := tea.NewProgram(m, tea.WithContext(ctx)) + + // Start the diagnostics process + go func() { + time.Sleep(100 * time.Millisecond) // Small delay to let the UI initialize + p.Send(getDiagnosticsCmd(ctx, cmd, cc)()) + }() + + model, err := p.Run() + if err != nil { + return err + } + + finalModel := model.(lintModel) + if finalModel.stopPolling != nil { + close(finalModel.stopPolling) + } + + if finalModel.error != nil { + return finalModel.error + } + + // If not in watch mode and we have blocking diagnostics, exit with error code + if !cmd.Bool("watch") && hasBlockingDiagnostic(finalModel.diagnostics) { + os.Exit(1) + } + + return nil +}