root.go 8.4 KB

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