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 tuiutil "github.com/charmbracelet/crush/internal/tui/util"
27 "github.com/charmbracelet/crush/internal/update"
28 "github.com/charmbracelet/crush/internal/version"
29 "github.com/charmbracelet/fang"
30 uv "github.com/charmbracelet/ultraviolet"
31 "github.com/charmbracelet/x/ansi"
32 "github.com/charmbracelet/x/exp/charmtone"
33 "github.com/charmbracelet/x/term"
34 "github.com/spf13/cobra"
35)
36
37func init() {
38 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
39 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
40 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
41 rootCmd.Flags().BoolP("help", "h", false, "Help")
42 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
43
44 rootCmd.AddCommand(
45 runCmd,
46 dirsCmd,
47 updateCmd,
48 updateProvidersCmd,
49 logsCmd,
50 schemaCmd,
51 loginCmd,
52 )
53}
54
55var rootCmd = &cobra.Command{
56 Use: "crush",
57 Short: "Terminal-based AI assistant for software development",
58 Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
59It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
60to assist developers in writing, debugging, and understanding code directly from the terminal.`,
61 Example: `
62# Run in interactive mode
63crush
64
65# Run with debug logging
66crush -d
67
68# Run with debug logging in a specific directory
69crush -d -c /path/to/project
70
71# Run with custom data directory
72crush -D /path/to/custom/.crush
73
74# Print version
75crush -v
76
77# Run a single non-interactive prompt
78crush run "Explain the use of context in Go"
79
80# Run in dangerous mode (auto-accept all permissions)
81crush -y
82 `,
83 RunE: func(cmd *cobra.Command, args []string) error {
84 app, err := setupAppWithProgressBar(cmd)
85 if err != nil {
86 return err
87 }
88 defer app.Shutdown()
89
90 event.AppInitialized()
91
92 // Set up the TUI.
93 var env uv.Environ = os.Environ()
94 ui := tui.New(app)
95 ui.QueryVersion = shouldQueryTerminalVersion(env)
96
97 program := tea.NewProgram(
98 ui,
99 tea.WithEnvironment(env),
100 tea.WithContext(cmd.Context()),
101 tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
102 go app.Subscribe(program)
103
104 // Create a cancellable context for the update check that gets cancelled
105 // when the TUI exits.
106 updateCtx, cancelUpdate := context.WithCancel(cmd.Context())
107 defer cancelUpdate()
108
109 // Start async update check unless disabled.
110 go checkForUpdateAsync(updateCtx, program)
111
112 if _, err := program.Run(); err != nil {
113 event.Error(err)
114 slog.Error("TUI run error", "error", err)
115 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
116 }
117 return nil
118 },
119 PostRun: func(cmd *cobra.Command, args []string) {
120 event.AppExited()
121 },
122}
123
124var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
125 ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
126 ███████████ ███████████
127████████████████████████████
128████████████████████████████
129██████████▀██████▀██████████
130██████████ ██████ ██████████
131▀▀██████▄████▄▄████▄██████▀▀
132 ████████████████████████
133 ████████████████████
134 ▀▀██████████▀▀
135 ▀▀▀▀▀▀
136`)
137
138// copied from cobra:
139const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
140`
141
142func Execute() {
143 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
144 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
145 // finally prepend it in the version template.
146 // Unfortunately cobra doesn't give us a way to set a function to handle
147 // printing the version, and PreRunE runs after the version is already
148 // handled, so that doesn't work either.
149 // This is the only way I could find that works relatively well.
150 versionTemplate := defaultVersionTemplate
151 if term.IsTerminal(os.Stdout.Fd()) {
152 var b bytes.Buffer
153 w := colorprofile.NewWriter(os.Stdout, os.Environ())
154 w.Forward = &b
155 _, _ = w.WriteString(heartbit.String())
156 versionTemplate = b.String() + "\n" + defaultVersionTemplate
157 }
158
159 // Check if version flag is present and add update notification if available.
160 if hasVersionFlag() {
161 if updateMsg := checkForUpdateSync(); updateMsg != "" {
162 versionTemplate += updateMsg
163 }
164 }
165
166 rootCmd.SetVersionTemplate(versionTemplate)
167
168 if err := fang.Execute(
169 context.Background(),
170 rootCmd,
171 fang.WithVersion(version.Version),
172 fang.WithNotifySignal(os.Interrupt),
173 ); err != nil {
174 os.Exit(1)
175 }
176}
177
178// hasVersionFlag checks if the version flag is present in os.Args.
179func hasVersionFlag() bool {
180 for _, arg := range os.Args {
181 if arg == "-v" || arg == "--version" {
182 return true
183 }
184 }
185 return false
186}
187
188// isAutoUpdateDisabled checks if update checks are disabled via env var.
189// Config is not loaded at this point (called before Execute), so only env var is checked.
190func isAutoUpdateDisabled() bool {
191 if str, ok := os.LookupEnv("CRUSH_DISABLE_AUTO_UPDATE"); ok {
192 v, _ := strconv.ParseBool(str)
193 return v
194 }
195 return false
196}
197
198// checkForUpdateSync performs a synchronous update check with a short timeout.
199// Returns a formatted update message if an update is available, empty string otherwise.
200func checkForUpdateSync() string {
201 if isAutoUpdateDisabled() {
202 return ""
203 }
204
205 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
206 defer cancel()
207
208 info, err := update.Check(ctx, version.Version, update.Default)
209 if err != nil {
210 return ""
211 }
212
213 if info.IsDevelopment() {
214 return info.DevelopmentVersionBrief()
215 }
216
217 if !info.Available() {
218 return ""
219 }
220
221 return fmt.Sprintf("\nUpdate available: v%s → v%s\nRun 'crush update apply' to install.\n", info.Current, info.Latest)
222}
223
224// checkForUpdateAsync checks for updates in the background and applies them if possible.
225func checkForUpdateAsync(ctx context.Context, program *tea.Program) {
226 // Check config (if loaded) or env var.
227 if isAutoUpdateDisabled() {
228 return
229 }
230 if cfg := config.Get(); cfg != nil && cfg.Options.DisableAutoUpdate {
231 return
232 }
233
234 checkCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
235 defer cancel()
236
237 info, err := update.Check(checkCtx, version.Version, update.Default)
238 if err != nil {
239 slog.Debug("background update check failed", "error", err)
240 return
241 }
242 if info.IsDevelopment() {
243 slog.Debug("skipping background update for development version", "version", info.Current)
244 return
245 }
246 if !info.Available() {
247 return
248 }
249
250 // Check if context was cancelled while checking.
251 if ctx.Err() != nil {
252 return
253 }
254
255 // Guard against nil release (shouldn't happen, but defensive).
256 if info.Release == nil {
257 slog.Debug("background update check returned nil release")
258 return
259 }
260
261 // Check install method.
262 method := update.DetectInstallMethod()
263 if !method.CanSelfUpdate() {
264 // Package manager install - show instructions.
265 program.Send(tuiutil.InfoMsg{
266 Type: tuiutil.InfoTypeUpdate,
267 Msg: fmt.Sprintf("Update available: v%s → v%s. Run: %s", info.Current, info.Latest, method.UpdateInstructions()),
268 TTL: 30 * time.Second,
269 })
270 return
271 }
272
273 // Attempt self-update.
274 asset, err := update.FindAsset(info.Release.Assets)
275 if err != nil {
276 slog.Debug("failed to find update asset for platform", "error", err)
277 program.Send(tuiutil.InfoMsg{
278 Type: tuiutil.InfoTypeWarn,
279 Msg: "Update available but failed to find asset. Run 'crush update' for details.",
280 TTL: 15 * time.Second,
281 })
282 return
283 }
284
285 // Check if context was cancelled before download.
286 if ctx.Err() != nil {
287 return
288 }
289
290 binaryPath, err := update.Download(checkCtx, asset, info.Release)
291 if err != nil {
292 // Don't show error message if context was cancelled (user exited).
293 if ctx.Err() != nil {
294 return
295 }
296 slog.Debug("background update download failed", "error", err)
297 program.Send(tuiutil.InfoMsg{
298 Type: tuiutil.InfoTypeWarn,
299 Msg: "Update download failed. Run 'crush update' for details.",
300 TTL: 15 * time.Second,
301 })
302 return
303 }
304 defer os.Remove(binaryPath)
305
306 // Check if context was cancelled before apply.
307 if ctx.Err() != nil {
308 return
309 }
310
311 if err := update.Apply(binaryPath); err != nil {
312 slog.Debug("background update apply failed", "error", err)
313 program.Send(tuiutil.InfoMsg{
314 Type: tuiutil.InfoTypeWarn,
315 Msg: "Update failed to install. Run 'crush update' for details.",
316 TTL: 15 * time.Second,
317 })
318 return
319 }
320
321 // Success!
322 program.Send(tuiutil.InfoMsg{
323 Type: tuiutil.InfoTypeUpdate,
324 Msg: fmt.Sprintf("Updated to v%s! Restart Crush to use the new version.", info.Latest),
325 TTL: 30 * time.Second,
326 })
327}
328
329func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
330 if termutil.SupportsProgressBar() {
331 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
332 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
333 }
334
335 return setupApp(cmd)
336}
337
338// setupApp handles the common setup logic for both interactive and non-interactive modes.
339// It returns the app instance, config, cleanup function, and any error.
340func setupApp(cmd *cobra.Command) (*app.App, error) {
341 debug, _ := cmd.Flags().GetBool("debug")
342 yolo, _ := cmd.Flags().GetBool("yolo")
343 dataDir, _ := cmd.Flags().GetString("data-dir")
344 ctx := cmd.Context()
345
346 cwd, err := ResolveCwd(cmd)
347 if err != nil {
348 return nil, err
349 }
350
351 cfg, err := config.Init(cwd, dataDir, debug)
352 if err != nil {
353 return nil, err
354 }
355
356 if cfg.Permissions == nil {
357 cfg.Permissions = &config.Permissions{}
358 }
359 cfg.Permissions.SkipRequests = yolo
360
361 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
362 return nil, err
363 }
364
365 // Connect to DB; this will also run migrations.
366 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
367 if err != nil {
368 return nil, err
369 }
370
371 appInstance, err := app.New(ctx, conn, cfg)
372 if err != nil {
373 slog.Error("Failed to create app instance", "error", err)
374 return nil, err
375 }
376
377 if shouldEnableMetrics() {
378 event.Init()
379 }
380
381 return appInstance, nil
382}
383
384func shouldEnableMetrics() bool {
385 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
386 return false
387 }
388 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
389 return false
390 }
391 if config.Get().Options.DisableMetrics {
392 return false
393 }
394 return true
395}
396
397func MaybePrependStdin(prompt string) (string, error) {
398 if term.IsTerminal(os.Stdin.Fd()) {
399 return prompt, nil
400 }
401 fi, err := os.Stdin.Stat()
402 if err != nil {
403 return prompt, err
404 }
405 if fi.Mode()&os.ModeNamedPipe == 0 {
406 return prompt, nil
407 }
408 bts, err := io.ReadAll(os.Stdin)
409 if err != nil {
410 return prompt, err
411 }
412 return string(bts) + "\n\n" + prompt, nil
413}
414
415func ResolveCwd(cmd *cobra.Command) (string, error) {
416 cwd, _ := cmd.Flags().GetString("cwd")
417 if cwd != "" {
418 err := os.Chdir(cwd)
419 if err != nil {
420 return "", fmt.Errorf("failed to change directory: %v", err)
421 }
422 return cwd, nil
423 }
424 cwd, err := os.Getwd()
425 if err != nil {
426 return "", fmt.Errorf("failed to get current working directory: %v", err)
427 }
428 return cwd, nil
429}
430
431func createDotCrushDir(dir string) error {
432 if err := os.MkdirAll(dir, 0o700); err != nil {
433 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
434 }
435
436 gitIgnorePath := filepath.Join(dir, ".gitignore")
437 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
438 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
439 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
440 }
441 }
442
443 return nil
444}
445
446func shouldQueryTerminalVersion(env uv.Environ) bool {
447 termType := env.Getenv("TERM")
448 termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
449 _, okSSHTTY := env.LookupEnv("SSH_TTY")
450 return (!okTermProg && !okSSHTTY) ||
451 (!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
452 // Terminals that do support XTVERSION.
453 stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
454}