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