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