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