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