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		// Create main context for the application
 76		ctx, cancel := context.WithCancel(context.Background())
 77		defer cancel()
 78
 79		// Connect DB, this will also run migrations
 80		conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
 81		if err != nil {
 82			return err
 83		}
 84
 85		app, err := app.New(ctx, conn, cfg)
 86		if err != nil {
 87			slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
 88			return err
 89		}
 90		// Defer shutdown here so it runs for both interactive and non-interactive modes
 91		defer app.Shutdown()
 92
 93		// Initialize MCP tools early for both modes
 94		initMCPTools(ctx, app, cfg)
 95
 96		prompt, err = maybePrependStdin(prompt)
 97		if err != nil {
 98			slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
 99			return err
100		}
101
102		// Non-interactive mode
103		if prompt != "" {
104			// Run non-interactive flow using the App method
105			return app.RunNonInteractive(ctx, prompt, quiet)
106		}
107
108		// Set up the TUI
109		program := tea.NewProgram(
110			tui.New(app),
111			tea.WithAltScreen(),
112			tea.WithKeyReleases(),
113			tea.WithUniformKeyLayout(),
114			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
115			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
116		)
117
118		go app.Subscribe(program)
119
120		if _, err := program.Run(); err != nil {
121			slog.Error(fmt.Sprintf("TUI run error: %v", err))
122			return fmt.Errorf("TUI error: %v", err)
123		}
124		app.Shutdown()
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	); 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}