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