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 "github.com/charmbracelet/crush/internal/stringext"
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
154// supportsProgressBar tries to determine whether the current terminal supports
155// progress bars by looking into environment variables.
156func supportsProgressBar() bool {
157 if !term.IsTerminal(os.Stderr.Fd()) {
158 return false
159 }
160 termProg := os.Getenv("TERM_PROGRAM")
161 _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
162
163 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
164}
165
166func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
167 if supportsProgressBar() {
168 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
169 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
170 }
171
172 return setupApp(cmd)
173}
174
175// setupApp handles the common setup logic for both interactive and non-interactive modes.
176// It returns the app instance, config, cleanup function, and any error.
177func setupApp(cmd *cobra.Command) (*app.App, error) {
178 debug, _ := cmd.Flags().GetBool("debug")
179 yolo, _ := cmd.Flags().GetBool("yolo")
180 dataDir, _ := cmd.Flags().GetString("data-dir")
181 ctx := cmd.Context()
182
183 cwd, err := ResolveCwd(cmd)
184 if err != nil {
185 return nil, err
186 }
187
188 cfg, err := config.Init(cwd, dataDir, debug)
189 if err != nil {
190 return nil, err
191 }
192
193 if cfg.Permissions == nil {
194 cfg.Permissions = &config.Permissions{}
195 }
196 cfg.Permissions.SkipRequests = yolo
197
198 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
199 return nil, err
200 }
201
202 // Connect to DB; this will also run migrations.
203 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
204 if err != nil {
205 return nil, err
206 }
207
208 appInstance, err := app.New(ctx, conn, cfg)
209 if err != nil {
210 slog.Error("Failed to create app instance", "error", err)
211 return nil, err
212 }
213
214 if shouldEnableMetrics() {
215 event.Init()
216 }
217
218 return appInstance, nil
219}
220
221func shouldEnableMetrics() bool {
222 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
223 return false
224 }
225 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
226 return false
227 }
228 if config.Get().Options.DisableMetrics {
229 return false
230 }
231 return true
232}
233
234func MaybePrependStdin(prompt string) (string, error) {
235 if term.IsTerminal(os.Stdin.Fd()) {
236 return prompt, nil
237 }
238 fi, err := os.Stdin.Stat()
239 if err != nil {
240 return prompt, err
241 }
242 // Check if stdin is a named pipe ( | ) or regular file ( < ).
243 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
244 return prompt, nil
245 }
246 bts, err := io.ReadAll(os.Stdin)
247 if err != nil {
248 return prompt, err
249 }
250 return string(bts) + "\n\n" + prompt, nil
251}
252
253func ResolveCwd(cmd *cobra.Command) (string, error) {
254 cwd, _ := cmd.Flags().GetString("cwd")
255 if cwd != "" {
256 err := os.Chdir(cwd)
257 if err != nil {
258 return "", fmt.Errorf("failed to change directory: %v", err)
259 }
260 return cwd, nil
261 }
262 cwd, err := os.Getwd()
263 if err != nil {
264 return "", fmt.Errorf("failed to get current working directory: %v", err)
265 }
266 return cwd, nil
267}
268
269func createDotCrushDir(dir string) error {
270 if err := os.MkdirAll(dir, 0o700); err != nil {
271 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
272 }
273
274 gitIgnorePath := filepath.Join(dir, ".gitignore")
275 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
276 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
277 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
278 }
279 }
280
281 return nil
282}
283
284func shouldQueryTerminalVersion(env uv.Environ) bool {
285 termType := env.Getenv("TERM")
286 termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
287 _, okSSHTTY := env.LookupEnv("SSH_TTY")
288 return (!okTermProg && !okSSHTTY) ||
289 (!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
290 // Terminals that do support XTVERSION.
291 stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
292}