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