1package cmd
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "log/slog"
8 "os"
9 "strings"
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/tui"
16 "github.com/charmbracelet/crush/internal/version"
17 "github.com/charmbracelet/fang"
18 "github.com/charmbracelet/x/term"
19 "github.com/spf13/cobra"
20)
21
22func init() {
23 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
24 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
25
26 rootCmd.Flags().BoolP("help", "h", false, "Help")
27 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
28
29 runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
30 rootCmd.AddCommand(runCmd)
31}
32
33var rootCmd = &cobra.Command{
34 Use: "crush",
35 Short: "Terminal-based AI assistant for software development",
36 Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
37It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
38to assist developers in writing, debugging, and understanding code directly from the terminal.`,
39 Example: `
40# Run in interactive mode
41crush
42
43# Run with debug logging
44crush -d
45
46# Run with debug logging in a specific directory
47crush -d -c /path/to/project
48
49# Print version
50crush -v
51
52# Run a single non-interactive prompt
53crush run "Explain the use of context in Go"
54
55# Run in dangerous mode (auto-accept all permissions)
56crush -y
57 `,
58 RunE: func(cmd *cobra.Command, args []string) error {
59 app, err := setupApp(cmd)
60 if err != nil {
61 return err
62 }
63 defer app.Shutdown()
64
65 // Set up the TUI.
66 program := tea.NewProgram(
67 tui.New(app),
68 tea.WithAltScreen(),
69 tea.WithContext(cmd.Context()),
70 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
71 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
72 )
73
74 go app.Subscribe(program)
75
76 if _, err := program.Run(); err != nil {
77 slog.Error("TUI run error", "error", err)
78 return fmt.Errorf("TUI error: %v", err)
79 }
80 return nil
81 },
82}
83
84var runCmd = &cobra.Command{
85 Use: "run [prompt...]",
86 Short: "Run a single non-interactive prompt",
87 Long: `Run a single prompt in non-interactive mode and exit.
88The prompt can be provided as arguments or piped from stdin.`,
89 Example: `
90# Run a simple prompt
91crush run Explain the use of context in Go
92
93# Pipe input from stdin
94echo "What is this code doing?" | crush run
95
96# Run with quiet mode (no spinner)
97crush run -q "Generate a README for this project"
98 `,
99 RunE: func(cmd *cobra.Command, args []string) error {
100 quiet, _ := cmd.Flags().GetBool("quiet")
101
102 app, err := setupApp(cmd)
103 if err != nil {
104 return err
105 }
106 defer app.Shutdown()
107
108 prompt := strings.Join(args, " ")
109
110 prompt, err = maybePrependStdin(prompt)
111 if err != nil {
112 slog.Error("Failed to read from stdin", "error", err)
113 return err
114 }
115
116 if prompt == "" {
117 return fmt.Errorf("no prompt provided")
118 }
119
120 // Run non-interactive flow using the App method
121 return app.RunNonInteractive(cmd.Context(), prompt, quiet)
122 },
123}
124
125func Execute() {
126 if err := fang.Execute(
127 context.Background(),
128 rootCmd,
129 fang.WithVersion(version.Version),
130 fang.WithNotifySignal(os.Interrupt),
131 ); err != nil {
132 os.Exit(1)
133 }
134}
135
136// setupApp handles the common setup logic for both interactive and non-interactive modes.
137// It returns the app instance, config, cleanup function, and any error.
138func setupApp(cmd *cobra.Command) (*app.App, error) {
139 debug, _ := cmd.Flags().GetBool("debug")
140 yolo, _ := cmd.Flags().GetBool("yolo")
141 ctx := cmd.Context()
142
143 cwd, err := resolveCwd(cmd)
144 if err != nil {
145 return nil, err
146 }
147
148 cfg, err := config.Init(cwd, debug)
149 if err != nil {
150 return nil, err
151 }
152
153 if cfg.Permissions == nil {
154 cfg.Permissions = &config.Permissions{}
155 }
156 cfg.Permissions.SkipRequests = yolo
157
158 // Connect to DB; this will also run migrations.
159 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
160 if err != nil {
161 return nil, err
162 }
163
164 appInstance, err := app.New(ctx, conn, cfg)
165 if err != nil {
166 slog.Error("Failed to create app instance", "error", err)
167 return nil, err
168 }
169
170 return appInstance, nil
171}
172
173func maybePrependStdin(prompt string) (string, error) {
174 if term.IsTerminal(os.Stdin.Fd()) {
175 return prompt, nil
176 }
177 fi, err := os.Stdin.Stat()
178 if err != nil {
179 return prompt, err
180 }
181 if fi.Mode()&os.ModeNamedPipe == 0 {
182 return prompt, nil
183 }
184 bts, err := io.ReadAll(os.Stdin)
185 if err != nil {
186 return prompt, err
187 }
188 return string(bts) + "\n\n" + prompt, nil
189}
190
191func resolveCwd(cmd *cobra.Command) (string, error) {
192 cwd, _ := cmd.Flags().GetString("cwd")
193 if cwd != "" {
194 err := os.Chdir(cwd)
195 if err != nil {
196 return "", fmt.Errorf("failed to change directory: %v", err)
197 }
198 return cwd, nil
199 }
200 cwd, err := os.Getwd()
201 if err != nil {
202 return "", fmt.Errorf("failed to get current working directory: %v", err)
203 }
204 return cwd, nil
205}