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