root.go

  1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"log/slog"
  8	"os"
  9	"syscall"
 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		// Defer shutdown here so it runs for both interactive and non-interactive modes
 90		defer app.Shutdown()
 91
 92		// Initialize MCP tools early for both modes
 93		initMCPTools(ctx, app, cfg)
 94
 95		prompt, err = maybePrependStdin(prompt)
 96		if err != nil {
 97			slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
 98			return err
 99		}
100
101		// Non-interactive mode
102		if prompt != "" {
103			// Run non-interactive flow using the App method
104			return app.RunNonInteractive(ctx, prompt, quiet)
105		}
106
107		// Set up the TUI
108		program := tea.NewProgram(
109			tui.New(app),
110			tea.WithAltScreen(),
111			tea.WithKeyReleases(),
112			tea.WithUniformKeyLayout(),
113			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
114			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
115		)
116
117		go app.Subscribe(program)
118
119		if _, err := program.Run(); err != nil {
120			slog.Error(fmt.Sprintf("TUI run error: %v", err))
121			return fmt.Errorf("TUI error: %v", err)
122		}
123		app.Shutdown()
124		return nil
125	},
126}
127
128func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
129	go func() {
130		defer log.RecoverPanic("MCP-goroutine", nil)
131
132		// Create a context with timeout for the initial MCP tools fetch
133		ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
134		defer cancel()
135
136		// Set this up once with proper error handling
137		agent.GetMcpTools(ctxWithTimeout, app.Permissions, cfg)
138		slog.Info("MCP message handling goroutine exiting")
139	}()
140}
141
142func Execute() {
143	if err := fang.Execute(
144		context.Background(),
145		rootCmd,
146		fang.WithVersion(version.Version),
147		fang.WithNotifySignal(os.Interrupt, syscall.SIGTERM),
148	); err != nil {
149		os.Exit(1)
150	}
151}
152
153func init() {
154	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
155
156	rootCmd.Flags().BoolP("help", "h", false, "Help")
157	rootCmd.Flags().BoolP("debug", "d", false, "Debug")
158	rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
159
160	// Add quiet flag to hide spinner in non-interactive mode
161	rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
162}
163
164func maybePrependStdin(prompt string) (string, error) {
165	if term.IsTerminal(os.Stdin.Fd()) {
166		return prompt, nil
167	}
168	fi, err := os.Stdin.Stat()
169	if err != nil {
170		return prompt, err
171	}
172	if fi.Mode()&os.ModeNamedPipe == 0 {
173		return prompt, nil
174	}
175	bts, err := io.ReadAll(os.Stdin)
176	if err != nil {
177		return prompt, err
178	}
179	return string(bts) + "\n\n" + prompt, nil
180}