1package cmd
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "log/slog"
9 "os"
10 "path/filepath"
11 "strconv"
12
13 tea "github.com/charmbracelet/bubbletea/v2"
14 "github.com/charmbracelet/colorprofile"
15 "github.com/charmbracelet/crush/internal/app"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/db"
18 "github.com/charmbracelet/crush/internal/event"
19 "github.com/charmbracelet/crush/internal/tui"
20 "github.com/charmbracelet/crush/internal/version"
21 "github.com/charmbracelet/fang"
22 "github.com/charmbracelet/lipgloss/v2"
23 "github.com/charmbracelet/x/exp/charmtone"
24 "github.com/charmbracelet/x/term"
25 "github.com/spf13/cobra"
26)
27
28func init() {
29 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
30 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
31 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
32
33 rootCmd.Flags().BoolP("help", "h", false, "Help")
34 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
35
36 rootCmd.AddCommand(runCmd)
37 rootCmd.AddCommand(updateProvidersCmd)
38}
39
40var rootCmd = &cobra.Command{
41 Use: "crush",
42 Short: "Terminal-based AI assistant for software development",
43 Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
44It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
45to assist developers in writing, debugging, and understanding code directly from the terminal.`,
46 Example: `
47# Run in interactive mode
48crush
49
50# Run with debug logging
51crush -d
52
53# Run with debug logging in a specific directory
54crush -d -c /path/to/project
55
56# Run with custom data directory
57crush -D /path/to/custom/.crush
58
59# Print version
60crush -v
61
62# Run a single non-interactive prompt
63crush run "Explain the use of context in Go"
64
65# Run in dangerous mode (auto-accept all permissions)
66crush -y
67 `,
68 RunE: func(cmd *cobra.Command, args []string) error {
69 app, err := setupApp(cmd)
70 if err != nil {
71 return err
72 }
73 defer app.Shutdown()
74
75 event.AppInitialized()
76
77 // Set up the TUI.
78 program := tea.NewProgram(
79 tui.New(app),
80 tea.WithAltScreen(),
81 tea.WithContext(cmd.Context()),
82 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
83 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
84 )
85
86 go app.Subscribe(program)
87
88 if _, err := program.Run(); err != nil {
89 event.Error(err)
90 slog.Error("TUI run error", "error", err)
91 return fmt.Errorf("TUI error: %v", err)
92 }
93 return nil
94 },
95 PostRun: func(cmd *cobra.Command, args []string) {
96 event.AppExited()
97 },
98}
99
100var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
101 ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
102 ███████████ ███████████
103████████████████████████████
104████████████████████████████
105██████████▀██████▀██████████
106██████████ ██████ ██████████
107▀▀██████▄████▄▄████▄██████▀▀
108 ████████████████████████
109 ████████████████████
110 ▀▀██████████▀▀
111 ▀▀▀▀▀▀
112`)
113
114// copied from cobra:
115const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
116`
117
118func Execute() {
119 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
120 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
121 // finally prepend it in the version template.
122 // Unfortunately cobra doesn't give us a way to set a function to handle
123 // printing the version, and PreRunE runs after the version is already
124 // handled, so that doesn't work either.
125 // This is the only way I could find that works relatively well.
126 if term.IsTerminal(os.Stdout.Fd()) {
127 var b bytes.Buffer
128 w := colorprofile.NewWriter(os.Stdout, os.Environ())
129 w.Forward = &b
130 _, _ = w.WriteString(heartbit.String())
131 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
132 }
133 if err := fang.Execute(
134 context.Background(),
135 rootCmd,
136 fang.WithVersion(version.Version),
137 fang.WithNotifySignal(os.Interrupt),
138 ); err != nil {
139 os.Exit(1)
140 }
141}
142
143// setupApp handles the common setup logic for both interactive and non-interactive modes.
144// It returns the app instance, config, cleanup function, and any error.
145func setupApp(cmd *cobra.Command) (*app.App, error) {
146 debug, _ := cmd.Flags().GetBool("debug")
147 yolo, _ := cmd.Flags().GetBool("yolo")
148 dataDir, _ := cmd.Flags().GetString("data-dir")
149 ctx := cmd.Context()
150
151 cwd, err := ResolveCwd(cmd)
152 if err != nil {
153 return nil, err
154 }
155
156 cfg, err := config.Init(cwd, dataDir, debug)
157 if err != nil {
158 return nil, err
159 }
160
161 if cfg.Permissions == nil {
162 cfg.Permissions = &config.Permissions{}
163 }
164 cfg.Permissions.SkipRequests = yolo
165
166 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
167 return nil, err
168 }
169
170 // Connect to DB; this will also run migrations.
171 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
172 if err != nil {
173 return nil, err
174 }
175
176 appInstance, err := app.New(ctx, conn, cfg)
177 if err != nil {
178 slog.Error("Failed to create app instance", "error", err)
179 return nil, err
180 }
181
182 if shouldEnableMetrics() {
183 event.Init()
184 }
185
186 return appInstance, nil
187}
188
189func shouldEnableMetrics() bool {
190 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
191 return false
192 }
193 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
194 return false
195 }
196 if config.Get().Options.DisableMetrics {
197 return false
198 }
199 return true
200}
201
202func MaybePrependStdin(prompt string) (string, error) {
203 if term.IsTerminal(os.Stdin.Fd()) {
204 return prompt, nil
205 }
206 fi, err := os.Stdin.Stat()
207 if err != nil {
208 return prompt, err
209 }
210 if fi.Mode()&os.ModeNamedPipe == 0 {
211 return prompt, nil
212 }
213 bts, err := io.ReadAll(os.Stdin)
214 if err != nil {
215 return prompt, err
216 }
217 return string(bts) + "\n\n" + prompt, nil
218}
219
220func ResolveCwd(cmd *cobra.Command) (string, error) {
221 cwd, _ := cmd.Flags().GetString("cwd")
222 if cwd != "" {
223 err := os.Chdir(cwd)
224 if err != nil {
225 return "", fmt.Errorf("failed to change directory: %v", err)
226 }
227 return cwd, nil
228 }
229 cwd, err := os.Getwd()
230 if err != nil {
231 return "", fmt.Errorf("failed to get current working directory: %v", err)
232 }
233 return cwd, nil
234}
235
236func createDotCrushDir(dir string) error {
237 if err := os.MkdirAll(dir, 0o700); err != nil {
238 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
239 }
240
241 gitIgnorePath := filepath.Join(dir, ".gitignore")
242 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
243 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
244 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
245 }
246 }
247
248 return nil
249}