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 shutdown here so it runs for both interactive and non-interactive modes
89 defer app.Shutdown()
90
91 // Initialize MCP tools early for both modes
92 initMCPTools(ctx, app, cfg)
93
94 prompt, err = maybePrependStdin(prompt)
95 if err != nil {
96 slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
97 return err
98 }
99
100 // Non-interactive mode
101 if prompt != "" {
102 // Run non-interactive flow using the App method
103 return app.RunNonInteractive(ctx, prompt, quiet)
104 }
105
106 // Set up the TUI
107 program := tea.NewProgram(
108 tui.New(app),
109 tea.WithAltScreen(),
110 tea.WithKeyReleases(),
111 tea.WithUniformKeyLayout(),
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 app.Shutdown()
123 return nil
124 },
125}
126
127func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
128 go func() {
129 defer log.RecoverPanic("MCP-goroutine", nil)
130
131 // Create a context with timeout for the initial MCP tools fetch
132 ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
133 defer cancel()
134
135 // Set this up once with proper error handling
136 agent.GetMcpTools(ctxWithTimeout, app.Permissions, cfg)
137 slog.Info("MCP message handling goroutine exiting")
138 }()
139}
140
141func Execute() {
142 if err := fang.Execute(
143 context.Background(),
144 rootCmd,
145 fang.WithVersion(version.Version),
146 fang.WithNotifySignal(os.Interrupt),
147 ); err != nil {
148 os.Exit(1)
149 }
150}
151
152func init() {
153 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
154
155 rootCmd.Flags().BoolP("help", "h", false, "Help")
156 rootCmd.Flags().BoolP("debug", "d", false, "Debug")
157 rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
158
159 // Add quiet flag to hide spinner in non-interactive mode
160 rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
161}
162
163func maybePrependStdin(prompt string) (string, error) {
164 if term.IsTerminal(os.Stdin.Fd()) {
165 return prompt, nil
166 }
167 fi, err := os.Stdin.Stat()
168 if err != nil {
169 return prompt, err
170 }
171 if fi.Mode()&os.ModeNamedPipe == 0 {
172 return prompt, nil
173 }
174 bts, err := io.ReadAll(os.Stdin)
175 if err != nil {
176 return prompt, err
177 }
178 return string(bts) + "\n\n" + prompt, nil
179}