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  # Run in dangerous mode (auto-accept all permissions)
 50  crush -y
 51  `,
 52	RunE: func(cmd *cobra.Command, args []string) error {
 53		// Load the config
 54		debug, _ := cmd.Flags().GetBool("debug")
 55		cwd, _ := cmd.Flags().GetString("cwd")
 56		prompt, _ := cmd.Flags().GetString("prompt")
 57		quiet, _ := cmd.Flags().GetBool("quiet")
 58		yolo, _ := cmd.Flags().GetBool("yolo")
 59
 60		if cwd != "" {
 61			err := os.Chdir(cwd)
 62			if err != nil {
 63				return fmt.Errorf("failed to change directory: %v", err)
 64			}
 65		}
 66		if cwd == "" {
 67			c, err := os.Getwd()
 68			if err != nil {
 69				return fmt.Errorf("failed to get current working directory: %v", err)
 70			}
 71			cwd = c
 72		}
 73
 74		cfg, err := config.Init(cwd, debug)
 75		if err != nil {
 76			return err
 77		}
 78		cfg.Options.SkipPermissionsRequests = yolo
 79
 80		ctx := cmd.Context()
 81
 82		// Connect DB, this will also run migrations
 83		conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
 84		if err != nil {
 85			return err
 86		}
 87
 88		app, err := app.New(ctx, conn, cfg)
 89		if err != nil {
 90			slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
 91			return err
 92		}
 93		defer app.Shutdown()
 94
 95		// Initialize MCP tools early for both modes
 96		initMCPTools(ctx, app, cfg)
 97
 98		prompt, err = maybePrependStdin(prompt)
 99		if err != nil {
100			slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
101			return err
102		}
103
104		// Non-interactive mode
105		if prompt != "" {
106			// Run non-interactive flow using the App method
107			return app.RunNonInteractive(ctx, prompt, quiet)
108		}
109
110		// Set up the TUI
111		program := tea.NewProgram(
112			tui.New(app),
113			tea.WithAltScreen(),
114			tea.WithContext(ctx),
115			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
116			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
117		)
118
119		go app.Subscribe(program)
120
121		if _, err := program.Run(); err != nil {
122			slog.Error(fmt.Sprintf("TUI run error: %v", err))
123			return fmt.Errorf("TUI error: %v", err)
124		}
125		return nil
126	},
127}
128
129func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
130	go func() {
131		defer log.RecoverPanic("MCP-goroutine", nil)
132
133		// Create a context with timeout for the initial MCP tools fetch
134		ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
135		defer cancel()
136
137		// Set this up once with proper error handling
138		agent.GetMcpTools(ctxWithTimeout, app.Permissions, cfg)
139		slog.Info("MCP message handling goroutine exiting")
140	}()
141}
142
143func Execute() {
144	if err := fang.Execute(
145		context.Background(),
146		rootCmd,
147		fang.WithVersion(version.Version),
148		fang.WithNotifySignal(os.Interrupt),
149	); err != nil {
150		os.Exit(1)
151	}
152}
153
154func init() {
155	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
156
157	rootCmd.Flags().BoolP("help", "h", false, "Help")
158	rootCmd.Flags().BoolP("debug", "d", false, "Debug")
159	rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
160	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
161
162	// Add quiet flag to hide spinner in non-interactive mode
163	rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
164}
165
166func maybePrependStdin(prompt string) (string, error) {
167	if term.IsTerminal(os.Stdin.Fd()) {
168		return prompt, nil
169	}
170	fi, err := os.Stdin.Stat()
171	if err != nil {
172		return prompt, err
173	}
174	if fi.Mode()&os.ModeNamedPipe == 0 {
175		return prompt, nil
176	}
177	bts, err := io.ReadAll(os.Stdin)
178	if err != nil {
179		return prompt, err
180	}
181	return string(bts) + "\n\n" + prompt, nil
182}