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