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 "time"
15
16 tea "charm.land/bubbletea/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/stringext"
24 termutil "github.com/charmbracelet/crush/internal/term"
25 "github.com/charmbracelet/crush/internal/tui"
26 "github.com/charmbracelet/crush/internal/update"
27 "github.com/charmbracelet/crush/internal/version"
28 "github.com/charmbracelet/fang"
29 uv "github.com/charmbracelet/ultraviolet"
30 "github.com/charmbracelet/x/ansi"
31 "github.com/charmbracelet/x/exp/charmtone"
32 "github.com/charmbracelet/x/term"
33 "github.com/spf13/cobra"
34)
35
36func init() {
37 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
38 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
39 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
40 rootCmd.Flags().BoolP("help", "h", false, "Help")
41 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
42
43 rootCmd.AddCommand(
44 runCmd,
45 dirsCmd,
46 updateCmd,
47 updateProvidersCmd,
48 logsCmd,
49 schemaCmd,
50 loginCmd,
51 )
52}
53
54var rootCmd = &cobra.Command{
55 Use: "crush",
56 Short: "Terminal-based AI assistant for software development",
57 Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
58It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
59to assist developers in writing, debugging, and understanding code directly from the terminal.`,
60 Example: `
61# Run in interactive mode
62crush
63
64# Run with debug logging
65crush -d
66
67# Run with debug logging in a specific directory
68crush -d -c /path/to/project
69
70# Run with custom data directory
71crush -D /path/to/custom/.crush
72
73# Print version
74crush -v
75
76# Run a single non-interactive prompt
77crush run "Explain the use of context in Go"
78
79# Run in dangerous mode (auto-accept all permissions)
80crush -y
81 `,
82 RunE: func(cmd *cobra.Command, args []string) error {
83 app, err := setupAppWithProgressBar(cmd)
84 if err != nil {
85 return err
86 }
87 defer app.Shutdown()
88
89 event.AppInitialized()
90
91 // Set up the TUI.
92 var env uv.Environ = os.Environ()
93 ui := tui.New(app)
94 ui.QueryVersion = shouldQueryTerminalVersion(env)
95
96 program := tea.NewProgram(
97 ui,
98 tea.WithEnvironment(env),
99 tea.WithContext(cmd.Context()),
100 tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
101 go app.Subscribe(program)
102
103 if _, err := program.Run(); err != nil {
104 event.Error(err)
105 slog.Error("TUI run error", "error", err)
106 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
107 }
108 return nil
109 },
110 PostRun: func(cmd *cobra.Command, args []string) {
111 event.AppExited()
112 },
113}
114
115var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
116 ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
117 ███████████ ███████████
118████████████████████████████
119████████████████████████████
120██████████▀██████▀██████████
121██████████ ██████ ██████████
122▀▀██████▄████▄▄████▄██████▀▀
123 ████████████████████████
124 ████████████████████
125 ▀▀██████████▀▀
126 ▀▀▀▀▀▀
127`)
128
129// copied from cobra:
130const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
131`
132
133func Execute() {
134 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
135 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
136 // finally prepend it in the version template.
137 // Unfortunately cobra doesn't give us a way to set a function to handle
138 // printing the version, and PreRunE runs after the version is already
139 // handled, so that doesn't work either.
140 // This is the only way I could find that works relatively well.
141 versionTemplate := defaultVersionTemplate
142 if term.IsTerminal(os.Stdout.Fd()) {
143 var b bytes.Buffer
144 w := colorprofile.NewWriter(os.Stdout, os.Environ())
145 w.Forward = &b
146 _, _ = w.WriteString(heartbit.String())
147 versionTemplate = b.String() + "\n" + defaultVersionTemplate
148 }
149
150 // Check if version flag is present and add update notification if available.
151 if hasVersionFlag() {
152 if updateMsg := checkForUpdateSync(); updateMsg != "" {
153 versionTemplate += updateMsg
154 }
155 }
156
157 rootCmd.SetVersionTemplate(versionTemplate)
158
159 if err := fang.Execute(
160 context.Background(),
161 rootCmd,
162 fang.WithVersion(version.Version),
163 fang.WithNotifySignal(os.Interrupt),
164 ); err != nil {
165 os.Exit(1)
166 }
167}
168
169// hasVersionFlag checks if the version flag is present in os.Args.
170func hasVersionFlag() bool {
171 for _, arg := range os.Args {
172 if arg == "-v" || arg == "--version" {
173 return true
174 }
175 }
176 return false
177}
178
179// checkForUpdateSync performs a synchronous update check with a short timeout.
180// Returns a formatted update message if an update is available, empty string otherwise.
181func checkForUpdateSync() string {
182 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
183 defer cancel()
184
185 info, err := update.Check(ctx, version.Version, update.Default)
186 if err != nil || !info.Available() {
187 return ""
188 }
189
190 if info.IsDevelopment() {
191 return fmt.Sprintf("\nThis is a development version of Crush. The latest stable release is v%s.\nRun 'crush update' to learn more.\n", info.Latest)
192 }
193
194 return fmt.Sprintf("\nUpdate available: v%s → v%s\nRun 'crush update apply' to install.\n", info.Current, info.Latest)
195}
196
197func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
198 if termutil.SupportsProgressBar() {
199 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
200 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
201 }
202
203 return setupApp(cmd)
204}
205
206// setupApp handles the common setup logic for both interactive and non-interactive modes.
207// It returns the app instance, config, cleanup function, and any error.
208func setupApp(cmd *cobra.Command) (*app.App, error) {
209 debug, _ := cmd.Flags().GetBool("debug")
210 yolo, _ := cmd.Flags().GetBool("yolo")
211 dataDir, _ := cmd.Flags().GetString("data-dir")
212 ctx := cmd.Context()
213
214 cwd, err := ResolveCwd(cmd)
215 if err != nil {
216 return nil, err
217 }
218
219 cfg, err := config.Init(cwd, dataDir, debug)
220 if err != nil {
221 return nil, err
222 }
223
224 if cfg.Permissions == nil {
225 cfg.Permissions = &config.Permissions{}
226 }
227 cfg.Permissions.SkipRequests = yolo
228
229 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
230 return nil, err
231 }
232
233 // Connect to DB; this will also run migrations.
234 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
235 if err != nil {
236 return nil, err
237 }
238
239 appInstance, err := app.New(ctx, conn, cfg)
240 if err != nil {
241 slog.Error("Failed to create app instance", "error", err)
242 return nil, err
243 }
244
245 if shouldEnableMetrics() {
246 event.Init()
247 }
248
249 return appInstance, nil
250}
251
252func shouldEnableMetrics() bool {
253 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
254 return false
255 }
256 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
257 return false
258 }
259 if config.Get().Options.DisableMetrics {
260 return false
261 }
262 return true
263}
264
265func MaybePrependStdin(prompt string) (string, error) {
266 if term.IsTerminal(os.Stdin.Fd()) {
267 return prompt, nil
268 }
269 fi, err := os.Stdin.Stat()
270 if err != nil {
271 return prompt, err
272 }
273 if fi.Mode()&os.ModeNamedPipe == 0 {
274 return prompt, nil
275 }
276 bts, err := io.ReadAll(os.Stdin)
277 if err != nil {
278 return prompt, err
279 }
280 return string(bts) + "\n\n" + prompt, nil
281}
282
283func ResolveCwd(cmd *cobra.Command) (string, error) {
284 cwd, _ := cmd.Flags().GetString("cwd")
285 if cwd != "" {
286 err := os.Chdir(cwd)
287 if err != nil {
288 return "", fmt.Errorf("failed to change directory: %v", err)
289 }
290 return cwd, nil
291 }
292 cwd, err := os.Getwd()
293 if err != nil {
294 return "", fmt.Errorf("failed to get current working directory: %v", err)
295 }
296 return cwd, nil
297}
298
299func createDotCrushDir(dir string) error {
300 if err := os.MkdirAll(dir, 0o700); err != nil {
301 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
302 }
303
304 gitIgnorePath := filepath.Join(dir, ".gitignore")
305 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
306 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
307 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
308 }
309 }
310
311 return nil
312}
313
314func shouldQueryTerminalVersion(env uv.Environ) bool {
315 termType := env.Getenv("TERM")
316 termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
317 _, okSSHTTY := env.LookupEnv("SSH_TTY")
318 return (!okTermProg && !okSSHTTY) ||
319 (!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
320 // Terminals that do support XTVERSION.
321 stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
322}