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