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