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