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 "charm.land/bubbletea/v2"
16 "charm.land/lipgloss/v2"
17 "github.com/charmbracelet/colorprofile"
18 "github.com/charmbracelet/crush/internal/app"
19 "github.com/charmbracelet/crush/internal/config"
20 "github.com/charmbracelet/crush/internal/db"
21 "github.com/charmbracelet/crush/internal/event"
22 termutil "github.com/charmbracelet/crush/internal/term"
23 "github.com/charmbracelet/crush/internal/tui"
24 "github.com/charmbracelet/crush/internal/version"
25 "github.com/charmbracelet/fang"
26 uv "github.com/charmbracelet/ultraviolet"
27 "github.com/charmbracelet/x/ansi"
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 rootCmd.Flags().BoolP("help", "h", false, "Help")
38 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
39
40 rootCmd.AddCommand(
41 runCmd,
42 dirsCmd,
43 updateProvidersCmd,
44 logsCmd,
45 schemaCmd,
46 loginCmd,
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 := setupAppWithProgressBar(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 ui := tui.New(app)
90 ui.QueryVersion = shouldQueryTerminalVersion(env)
91
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 go app.Subscribe(program)
98
99 if _, err := program.Run(); err != nil {
100 event.Error(err)
101 slog.Error("TUI run error", "error", err)
102 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
103 }
104 return nil
105 },
106 PostRun: func(cmd *cobra.Command, args []string) {
107 event.AppExited()
108 },
109}
110
111var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
112 ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
113 ███████████ ███████████
114████████████████████████████
115████████████████████████████
116██████████▀██████▀██████████
117██████████ ██████ ██████████
118▀▀██████▄████▄▄████▄██████▀▀
119 ████████████████████████
120 ████████████████████
121 ▀▀██████████▀▀
122 ▀▀▀▀▀▀
123`)
124
125// copied from cobra:
126const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
127`
128
129func Execute() {
130 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
131 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
132 // finally prepend it in the version template.
133 // Unfortunately cobra doesn't give us a way to set a function to handle
134 // printing the version, and PreRunE runs after the version is already
135 // handled, so that doesn't work either.
136 // This is the only way I could find that works relatively well.
137 if term.IsTerminal(os.Stdout.Fd()) {
138 var b bytes.Buffer
139 w := colorprofile.NewWriter(os.Stdout, os.Environ())
140 w.Forward = &b
141 _, _ = w.WriteString(heartbit.String())
142 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
143 }
144 if err := fang.Execute(
145 context.Background(),
146 rootCmd,
147 fang.WithVersion(version.Version),
148 fang.WithNotifySignal(os.Interrupt),
149 ); err != nil {
150 os.Exit(1)
151 }
152}
153
154func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
155 if termutil.SupportsProgressBar() {
156 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
157 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
158 }
159
160 return setupApp(cmd)
161}
162
163// setupApp handles the common setup logic for both interactive and non-interactive modes.
164// It returns the app instance, config, cleanup function, and any error.
165func setupApp(cmd *cobra.Command) (*app.App, error) {
166 debug, _ := cmd.Flags().GetBool("debug")
167 yolo, _ := cmd.Flags().GetBool("yolo")
168 dataDir, _ := cmd.Flags().GetString("data-dir")
169 ctx := cmd.Context()
170
171 cwd, err := ResolveCwd(cmd)
172 if err != nil {
173 return nil, err
174 }
175
176 cfg, err := config.Init(cwd, dataDir, debug)
177 if err != nil {
178 return nil, err
179 }
180
181 if cfg.Permissions == nil {
182 cfg.Permissions = &config.Permissions{}
183 }
184 cfg.Permissions.SkipRequests = yolo
185
186 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
187 return nil, err
188 }
189
190 // Connect to DB; this will also run migrations.
191 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
192 if err != nil {
193 return nil, err
194 }
195
196 appInstance, err := app.New(ctx, conn, cfg)
197 if err != nil {
198 slog.Error("Failed to create app instance", "error", err)
199 return nil, err
200 }
201
202 if shouldEnableMetrics() {
203 event.Init()
204 }
205
206 return appInstance, nil
207}
208
209func shouldEnableMetrics() bool {
210 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
211 return false
212 }
213 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
214 return false
215 }
216 if config.Get().Options.DisableMetrics {
217 return false
218 }
219 return true
220}
221
222func MaybePrependStdin(prompt string) (string, error) {
223 if term.IsTerminal(os.Stdin.Fd()) {
224 return prompt, nil
225 }
226 fi, err := os.Stdin.Stat()
227 if err != nil {
228 return prompt, err
229 }
230 if fi.Mode()&os.ModeNamedPipe == 0 {
231 return prompt, nil
232 }
233 bts, err := io.ReadAll(os.Stdin)
234 if err != nil {
235 return prompt, err
236 }
237 return string(bts) + "\n\n" + prompt, nil
238}
239
240func ResolveCwd(cmd *cobra.Command) (string, error) {
241 cwd, _ := cmd.Flags().GetString("cwd")
242 if cwd != "" {
243 err := os.Chdir(cwd)
244 if err != nil {
245 return "", fmt.Errorf("failed to change directory: %v", err)
246 }
247 return cwd, nil
248 }
249 cwd, err := os.Getwd()
250 if err != nil {
251 return "", fmt.Errorf("failed to get current working directory: %v", err)
252 }
253 return cwd, nil
254}
255
256func createDotCrushDir(dir string) error {
257 if err := os.MkdirAll(dir, 0o700); err != nil {
258 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
259 }
260
261 gitIgnorePath := filepath.Join(dir, ".gitignore")
262 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
263 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
264 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
265 }
266 }
267
268 return nil
269}
270
271func shouldQueryTerminalVersion(env uv.Environ) bool {
272 termType := env.Getenv("TERM")
273 termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
274 _, okSSHTTY := env.LookupEnv("SSH_TTY")
275 return (!okTermProg && !okSSHTTY) ||
276 (!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
277 // Terminals that do support XTVERSION.
278 strings.Contains(termType, "ghostty") ||
279 strings.Contains(termType, "wezterm") ||
280 strings.Contains(termType, "alacritty") ||
281 strings.Contains(termType, "kitty") ||
282 strings.Contains(termType, "rio")
283}