root.go

  1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"log/slog"
  8	"os"
  9	"sync"
 10	"time"
 11
 12	tea "github.com/charmbracelet/bubbletea/v2"
 13	"github.com/charmbracelet/crush/internal/app"
 14	"github.com/charmbracelet/crush/internal/config"
 15	"github.com/charmbracelet/crush/internal/db"
 16	"github.com/charmbracelet/crush/internal/llm/agent"
 17	"github.com/charmbracelet/crush/internal/log"
 18	"github.com/charmbracelet/crush/internal/tui"
 19	"github.com/charmbracelet/crush/internal/version"
 20	"github.com/charmbracelet/fang"
 21	"github.com/charmbracelet/x/term"
 22	"github.com/spf13/cobra"
 23)
 24
 25var rootCmd = &cobra.Command{
 26	Use:   "crush",
 27	Short: "Terminal-based AI assistant for software development",
 28	Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
 29It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
 30to assist developers in writing, debugging, and understanding code directly from the terminal.`,
 31	Example: `
 32  # Run in interactive mode
 33  crush
 34
 35  # Run with debug logging
 36  crush -d
 37
 38  # Run with debug slog.in a specific directory
 39  crush -d -c /path/to/project
 40
 41  # Print version
 42  crush -v
 43
 44  # Run a single non-interactive prompt
 45  crush -p "Explain the use of context in Go"
 46
 47  # Run a single non-interactive prompt with JSON output format
 48  crush -p "Explain the use of context in Go" -f json
 49  `,
 50	RunE: func(cmd *cobra.Command, args []string) error {
 51		// Load the config
 52		debug, _ := cmd.Flags().GetBool("debug")
 53		cwd, _ := cmd.Flags().GetString("cwd")
 54		prompt, _ := cmd.Flags().GetString("prompt")
 55		quiet, _ := cmd.Flags().GetBool("quiet")
 56
 57		if cwd != "" {
 58			err := os.Chdir(cwd)
 59			if err != nil {
 60				return fmt.Errorf("failed to change directory: %v", err)
 61			}
 62		}
 63		if cwd == "" {
 64			c, err := os.Getwd()
 65			if err != nil {
 66				return fmt.Errorf("failed to get current working directory: %v", err)
 67			}
 68			cwd = c
 69		}
 70
 71		cfg, err := config.Init(cwd, debug)
 72		if err != nil {
 73			return err
 74		}
 75
 76		ctx := cmd.Context()
 77
 78		// Connect DB, this will also run migrations
 79		conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
 80		if err != nil {
 81			return err
 82		}
 83
 84		app, err := app.New(ctx, conn, cfg)
 85		if err != nil {
 86			slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
 87			return err
 88		}
 89
 90		// Set up shutdown handling that works for both normal exit and signal interruption
 91		var shutdownOnce sync.Once
 92		shutdown := func() {
 93			shutdownOnce.Do(func() {
 94				slog.Info("Shutting down application")
 95				app.Shutdown()
 96			})
 97		}
 98		defer shutdown()
 99
100		// Handle context cancellation (from signals) in a goroutine
101		go func() {
102			<-ctx.Done()
103			slog.Info("Context cancelled, initiating shutdown")
104			shutdown()
105		}()
106
107		// Initialize MCP tools early for both modes
108		initMCPTools(ctx, app, cfg)
109
110		prompt, err = maybePrependStdin(prompt)
111		if err != nil {
112			slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
113			return err
114		}
115
116		// Non-interactive mode
117		if prompt != "" {
118			// Run non-interactive flow using the App method
119			return app.RunNonInteractive(ctx, prompt, quiet)
120		}
121
122		// Set up the TUI
123		program := tea.NewProgram(
124			tui.New(app),
125			tea.WithAltScreen(),
126			tea.WithKeyReleases(),
127			tea.WithUniformKeyLayout(),
128			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
129			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
130		)
131
132		go app.Subscribe(program)
133
134		if _, err := program.Run(); err != nil {
135			slog.Error(fmt.Sprintf("TUI run error: %v", err))
136			return fmt.Errorf("TUI error: %v", err)
137		}
138		return nil
139	},
140}
141
142func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
143	go func() {
144		defer log.RecoverPanic("MCP-goroutine", nil)
145
146		// Create a context with timeout for the initial MCP tools fetch
147		ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
148		defer cancel()
149
150		// Set this up once with proper error handling
151		agent.GetMcpTools(ctxWithTimeout, app.Permissions, cfg)
152		slog.Info("MCP message handling goroutine exiting")
153	}()
154}
155
156func Execute() {
157	if err := fang.Execute(
158		context.Background(),
159		rootCmd,
160		fang.WithVersion(version.Version),
161		fang.WithNotifySignal(os.Interrupt),
162	); err != nil {
163		os.Exit(1)
164	}
165}
166
167func init() {
168	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
169
170	rootCmd.Flags().BoolP("help", "h", false, "Help")
171	rootCmd.Flags().BoolP("debug", "d", false, "Debug")
172	rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
173
174	// Add quiet flag to hide spinner in non-interactive mode
175	rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
176}
177
178func maybePrependStdin(prompt string) (string, error) {
179	if term.IsTerminal(os.Stdin.Fd()) {
180		return prompt, nil
181	}
182	fi, err := os.Stdin.Stat()
183	if err != nil {
184		return prompt, err
185	}
186	if fi.Mode()&os.ModeNamedPipe == 0 {
187		return prompt, nil
188	}
189	bts, err := io.ReadAll(os.Stdin)
190	if err != nil {
191		return prompt, err
192	}
193	return string(bts) + "\n\n" + prompt, nil
194}