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