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