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 if supportsProgressBar() {
183 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
184 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
185 }
186
187 return setupApp(cmd)
188}
189
190// setupApp handles the common setup logic for both interactive and non-interactive modes.
191// It returns the app instance, config, cleanup function, and any error.
192func setupApp(cmd *cobra.Command) (*app.App, error) {
193 debug, _ := cmd.Flags().GetBool("debug")
194 yolo, _ := cmd.Flags().GetBool("yolo")
195 dataDir, _ := cmd.Flags().GetString("data-dir")
196 ctx := cmd.Context()
197
198 cwd, err := ResolveCwd(cmd)
199 if err != nil {
200 return nil, err
201 }
202
203 cfg, err := config.Init(cwd, dataDir, debug)
204 if err != nil {
205 return nil, err
206 }
207
208 if cfg.Permissions == nil {
209 cfg.Permissions = &config.Permissions{}
210 }
211 cfg.Permissions.SkipRequests = yolo
212
213 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
214 return nil, err
215 }
216
217 // Register this project in the centralized projects list.
218 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
219 slog.Warn("Failed to register project", "error", err)
220 // Non-fatal: continue even if registration fails
221 }
222
223 // Connect to DB; this will also run migrations.
224 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
225 if err != nil {
226 return nil, err
227 }
228
229 appInstance, err := app.New(ctx, conn, cfg)
230 if err != nil {
231 slog.Error("Failed to create app instance", "error", err)
232 return nil, err
233 }
234
235 if shouldEnableMetrics() {
236 event.Init()
237 }
238
239 return appInstance, nil
240}
241
242func shouldEnableMetrics() bool {
243 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
244 return false
245 }
246 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
247 return false
248 }
249 if config.Get().Options.DisableMetrics {
250 return false
251 }
252 return true
253}
254
255func MaybePrependStdin(prompt string) (string, error) {
256 if term.IsTerminal(os.Stdin.Fd()) {
257 return prompt, nil
258 }
259 fi, err := os.Stdin.Stat()
260 if err != nil {
261 return prompt, err
262 }
263 // Check if stdin is a named pipe ( | ) or regular file ( < ).
264 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
265 return prompt, nil
266 }
267 bts, err := io.ReadAll(os.Stdin)
268 if err != nil {
269 return prompt, err
270 }
271 return string(bts) + "\n\n" + prompt, nil
272}
273
274func ResolveCwd(cmd *cobra.Command) (string, error) {
275 cwd, _ := cmd.Flags().GetString("cwd")
276 if cwd != "" {
277 err := os.Chdir(cwd)
278 if err != nil {
279 return "", fmt.Errorf("failed to change directory: %v", err)
280 }
281 return cwd, nil
282 }
283 cwd, err := os.Getwd()
284 if err != nil {
285 return "", fmt.Errorf("failed to get current working directory: %v", err)
286 }
287 return cwd, nil
288}
289
290func createDotCrushDir(dir string) error {
291 if err := os.MkdirAll(dir, 0o700); err != nil {
292 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
293 }
294
295 gitIgnorePath := filepath.Join(dir, ".gitignore")
296 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
297 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
298 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
299 }
300 }
301
302 return nil
303}
304
305// TODO: Remove me after dropping the old TUI.
306func shouldQueryCapabilities(env uv.Environ) bool {
307 const osVendorTypeApple = "Apple"
308 termType := env.Getenv("TERM")
309 termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
310 _, okSSHTTY := env.LookupEnv("SSH_TTY")
311 if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
312 return false
313 }
314 return (!okTermProg && !okSSHTTY) ||
315 (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
316 // Terminals that do support XTVERSION.
317 xstrings.ContainsAnyOf(termType, kittyTerminals...)
318}