root.go

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