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 // Create main context for the application
76 ctx, cancel := context.WithCancel(context.Background())
77 defer cancel()
78
79 // Connect DB, this will also run migrations
80 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
81 if err != nil {
82 return err
83 }
84
85 app, err := app.New(ctx, conn, cfg)
86 if err != nil {
87 slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
88 return err
89 }
90 // Defer shutdown here so it runs for both interactive and non-interactive modes
91 defer app.Shutdown()
92
93 // Initialize MCP tools early for both modes
94 initMCPTools(ctx, app, cfg)
95
96 prompt, err = maybePrependStdin(prompt)
97 if err != nil {
98 slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
99 return err
100 }
101
102 // Non-interactive mode
103 if prompt != "" {
104 // Run non-interactive flow using the App method
105 return app.RunNonInteractive(ctx, prompt, quiet)
106 }
107
108 // Set up the TUI
109 program := tea.NewProgram(
110 tui.New(app),
111 tea.WithAltScreen(),
112 tea.WithKeyReleases(),
113 tea.WithUniformKeyLayout(),
114 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
115 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
116 )
117
118 go app.Subscribe(program)
119
120 if _, err := program.Run(); err != nil {
121 slog.Error(fmt.Sprintf("TUI run error: %v", err))
122 return fmt.Errorf("TUI error: %v", err)
123 }
124 app.Shutdown()
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 ); 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}