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