root.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. package cmd
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "os"
  10. "path/filepath"
  11. "strconv"
  12. tea "github.com/charmbracelet/bubbletea/v2"
  13. "github.com/charmbracelet/colorprofile"
  14. "github.com/charmbracelet/crush/internal/app"
  15. "github.com/charmbracelet/crush/internal/config"
  16. "github.com/charmbracelet/crush/internal/db"
  17. "github.com/charmbracelet/crush/internal/event"
  18. "github.com/charmbracelet/crush/internal/tui"
  19. "github.com/charmbracelet/crush/internal/version"
  20. "github.com/charmbracelet/fang"
  21. "github.com/charmbracelet/lipgloss/v2"
  22. "github.com/charmbracelet/x/exp/charmtone"
  23. "github.com/charmbracelet/x/term"
  24. "github.com/spf13/cobra"
  25. )
  26. func init() {
  27. rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
  28. rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
  29. rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
  30. rootCmd.Flags().BoolP("help", "h", false, "Help")
  31. rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
  32. rootCmd.AddCommand(
  33. runCmd,
  34. dirsCmd,
  35. updateProvidersCmd,
  36. logsCmd,
  37. schemaCmd,
  38. )
  39. }
  40. var rootCmd = &cobra.Command{
  41. Use: "crush",
  42. Short: "Terminal-based AI assistant for software development",
  43. Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
  44. It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
  45. to assist developers in writing, debugging, and understanding code directly from the terminal.`,
  46. Example: `
  47. # Run in interactive mode
  48. crush
  49. # Run with debug logging
  50. crush -d
  51. # Run with debug logging in a specific directory
  52. crush -d -c /path/to/project
  53. # Run with custom data directory
  54. crush -D /path/to/custom/.crush
  55. # Print version
  56. crush -v
  57. # Run a single non-interactive prompt
  58. crush run "Explain the use of context in Go"
  59. # Run in dangerous mode (auto-accept all permissions)
  60. crush -y
  61. `,
  62. RunE: func(cmd *cobra.Command, args []string) error {
  63. app, err := setupApp(cmd)
  64. if err != nil {
  65. return err
  66. }
  67. defer app.Shutdown()
  68. event.AppInitialized()
  69. // Set up the TUI.
  70. program := tea.NewProgram(
  71. tui.New(app),
  72. tea.WithContext(cmd.Context()),
  73. tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
  74. go app.Subscribe(program)
  75. if _, err := program.Run(); err != nil {
  76. event.Error(err)
  77. slog.Error("TUI run error", "error", err)
  78. 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
  79. }
  80. return nil
  81. },
  82. PostRun: func(cmd *cobra.Command, args []string) {
  83. event.AppExited()
  84. },
  85. }
  86. var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
  87. ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
  88. ███████████ ███████████
  89. ████████████████████████████
  90. ████████████████████████████
  91. ██████████▀██████▀██████████
  92. ██████████ ██████ ██████████
  93. ▀▀██████▄████▄▄████▄██████▀▀
  94. ████████████████████████
  95. ████████████████████
  96. ▀▀██████████▀▀
  97. ▀▀▀▀▀▀
  98. `)
  99. // copied from cobra:
  100. const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
  101. `
  102. func Execute() {
  103. // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
  104. // it forward to a bytes.Buffer, write the colored heartbit to it, and then
  105. // finally prepend it in the version template.
  106. // Unfortunately cobra doesn't give us a way to set a function to handle
  107. // printing the version, and PreRunE runs after the version is already
  108. // handled, so that doesn't work either.
  109. // This is the only way I could find that works relatively well.
  110. if term.IsTerminal(os.Stdout.Fd()) {
  111. var b bytes.Buffer
  112. w := colorprofile.NewWriter(os.Stdout, os.Environ())
  113. w.Forward = &b
  114. _, _ = w.WriteString(heartbit.String())
  115. rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
  116. }
  117. if err := fang.Execute(
  118. context.Background(),
  119. rootCmd,
  120. fang.WithVersion(version.Version),
  121. fang.WithNotifySignal(os.Interrupt),
  122. ); err != nil {
  123. os.Exit(1)
  124. }
  125. }
  126. // setupApp handles the common setup logic for both interactive and non-interactive modes.
  127. // It returns the app instance, config, cleanup function, and any error.
  128. func setupApp(cmd *cobra.Command) (*app.App, error) {
  129. debug, _ := cmd.Flags().GetBool("debug")
  130. yolo, _ := cmd.Flags().GetBool("yolo")
  131. dataDir, _ := cmd.Flags().GetString("data-dir")
  132. ctx := cmd.Context()
  133. cwd, err := ResolveCwd(cmd)
  134. if err != nil {
  135. return nil, err
  136. }
  137. cfg, err := config.Init(cwd, dataDir, debug)
  138. if err != nil {
  139. return nil, err
  140. }
  141. if cfg.Permissions == nil {
  142. cfg.Permissions = &config.Permissions{}
  143. }
  144. cfg.Permissions.SkipRequests = yolo
  145. if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
  146. return nil, err
  147. }
  148. // Connect to DB; this will also run migrations.
  149. conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
  150. if err != nil {
  151. return nil, err
  152. }
  153. appInstance, err := app.New(ctx, conn, cfg)
  154. if err != nil {
  155. slog.Error("Failed to create app instance", "error", err)
  156. return nil, err
  157. }
  158. if shouldEnableMetrics() {
  159. event.Init()
  160. }
  161. return appInstance, nil
  162. }
  163. func shouldEnableMetrics() bool {
  164. if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
  165. return false
  166. }
  167. if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
  168. return false
  169. }
  170. if config.Get().Options.DisableMetrics {
  171. return false
  172. }
  173. return true
  174. }
  175. func MaybePrependStdin(prompt string) (string, error) {
  176. if term.IsTerminal(os.Stdin.Fd()) {
  177. return prompt, nil
  178. }
  179. fi, err := os.Stdin.Stat()
  180. if err != nil {
  181. return prompt, err
  182. }
  183. if fi.Mode()&os.ModeNamedPipe == 0 {
  184. return prompt, nil
  185. }
  186. bts, err := io.ReadAll(os.Stdin)
  187. if err != nil {
  188. return prompt, err
  189. }
  190. return string(bts) + "\n\n" + prompt, nil
  191. }
  192. func ResolveCwd(cmd *cobra.Command) (string, error) {
  193. cwd, _ := cmd.Flags().GetString("cwd")
  194. if cwd != "" {
  195. err := os.Chdir(cwd)
  196. if err != nil {
  197. return "", fmt.Errorf("failed to change directory: %v", err)
  198. }
  199. return cwd, nil
  200. }
  201. cwd, err := os.Getwd()
  202. if err != nil {
  203. return "", fmt.Errorf("failed to get current working directory: %v", err)
  204. }
  205. return cwd, nil
  206. }
  207. func createDotCrushDir(dir string) error {
  208. if err := os.MkdirAll(dir, 0o700); err != nil {
  209. return fmt.Errorf("failed to create data directory: %q %w", dir, err)
  210. }
  211. gitIgnorePath := filepath.Join(dir, ".gitignore")
  212. if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
  213. if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
  214. return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
  215. }
  216. }
  217. return nil
  218. }