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 // Use the context from the command which includes signal handling
77 ctx := cmd.Context()
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
91 // Set up shutdown handling that works for both normal exit and signal interruption
92 var shutdownOnce sync.Once
93 shutdown := func() {
94 shutdownOnce.Do(func() {
95 slog.Info("Shutting down application")
96 app.Shutdown()
97 })
98 }
99 defer shutdown()
100
101 // Handle context cancellation (from signals) in a goroutine
102 go func() {
103 <-ctx.Done()
104 slog.Info("Context cancelled, initiating shutdown")
105 shutdown()
106 }()
107
108 // Initialize MCP tools early for both modes
109 initMCPTools(ctx, app, cfg)
110
111 prompt, err = maybePrependStdin(prompt)
112 if err != nil {
113 slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
114 return err
115 }
116
117 // Non-interactive mode
118 if prompt != "" {
119 // Run non-interactive flow using the App method
120 return app.RunNonInteractive(ctx, prompt, quiet)
121 }
122
123 // Set up the TUI
124 program := tea.NewProgram(
125 tui.New(app),
126 tea.WithAltScreen(),
127 tea.WithKeyReleases(),
128 tea.WithUniformKeyLayout(),
129 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
130 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
131 )
132
133 go app.Subscribe(program)
134
135 if _, err := program.Run(); err != nil {
136 slog.Error(fmt.Sprintf("TUI run error: %v", err))
137 return fmt.Errorf("TUI error: %v", err)
138 }
139 return nil
140 },
141}
142
143func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
144 go func() {
145 defer log.RecoverPanic("MCP-goroutine", nil)
146
147 // Create a context with timeout for the initial MCP tools fetch
148 ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
149 defer cancel()
150
151 // Set this up once with proper error handling
152 agent.GetMcpTools(ctxWithTimeout, app.Permissions, cfg)
153 slog.Info("MCP message handling goroutine exiting")
154 }()
155}
156
157func Execute(ctx context.Context) {
158 if err := fang.Execute(
159 ctx,
160 rootCmd,
161 fang.WithVersion(version.Version),
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}