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