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