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 # Run in dangerous mode (auto-accept all permissions)
50 crush -y
51 `,
52 RunE: func(cmd *cobra.Command, args []string) error {
53 // Load the config
54 debug, _ := cmd.Flags().GetBool("debug")
55 cwd, _ := cmd.Flags().GetString("cwd")
56 prompt, _ := cmd.Flags().GetString("prompt")
57 quiet, _ := cmd.Flags().GetBool("quiet")
58 yolo, _ := cmd.Flags().GetBool("yolo")
59
60 if cwd != "" {
61 err := os.Chdir(cwd)
62 if err != nil {
63 return fmt.Errorf("failed to change directory: %v", err)
64 }
65 }
66 if cwd == "" {
67 c, err := os.Getwd()
68 if err != nil {
69 return fmt.Errorf("failed to get current working directory: %v", err)
70 }
71 cwd = c
72 }
73
74 cfg, err := config.Init(cwd, debug)
75 if err != nil {
76 return err
77 }
78 cfg.Options.SkipPermissionsRequests = yolo
79
80 ctx := cmd.Context()
81
82 // Connect DB, this will also run migrations
83 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
84 if err != nil {
85 return err
86 }
87
88 app, err := app.New(ctx, conn, cfg)
89 if err != nil {
90 slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
91 return err
92 }
93 defer app.Shutdown()
94
95 // Initialize MCP tools early for both modes
96 initMCPTools(ctx, app, cfg)
97
98 prompt, err = maybePrependStdin(prompt)
99 if err != nil {
100 slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
101 return err
102 }
103
104 // Non-interactive mode
105 if prompt != "" {
106 // Run non-interactive flow using the App method
107 return app.RunNonInteractive(ctx, prompt, quiet)
108 }
109
110 // Set up the TUI
111 program := tea.NewProgram(
112 tui.New(app),
113 tea.WithAltScreen(),
114 tea.WithContext(ctx),
115 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
116 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
117 )
118
119 go app.Subscribe(program)
120
121 if _, err := program.Run(); err != nil {
122 slog.Error(fmt.Sprintf("TUI run error: %v", err))
123 return fmt.Errorf("TUI error: %v", err)
124 }
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 fang.WithNotifySignal(os.Interrupt),
149 ); err != nil {
150 os.Exit(1)
151 }
152}
153
154func init() {
155 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
156
157 rootCmd.Flags().BoolP("help", "h", false, "Help")
158 rootCmd.Flags().BoolP("debug", "d", false, "Debug")
159 rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
160 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
161
162 // Add quiet flag to hide spinner in non-interactive mode
163 rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
164}
165
166func maybePrependStdin(prompt string) (string, error) {
167 if term.IsTerminal(os.Stdin.Fd()) {
168 return prompt, nil
169 }
170 fi, err := os.Stdin.Stat()
171 if err != nil {
172 return prompt, err
173 }
174 if fi.Mode()&os.ModeNamedPipe == 0 {
175 return prompt, nil
176 }
177 bts, err := io.ReadAll(os.Stdin)
178 if err != nil {
179 return prompt, err
180 }
181 return string(bts) + "\n\n" + prompt, nil
182}