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