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