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