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