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