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