root.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. package cmd
  2. import (
  3. "bytes"
  4. "context"
  5. _ "embed"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "log/slog"
  10. "os"
  11. "path/filepath"
  12. "strconv"
  13. "strings"
  14. tea "charm.land/bubbletea/v2"
  15. "charm.land/fang/v2"
  16. "charm.land/lipgloss/v2"
  17. "github.com/charmbracelet/colorprofile"
  18. "github.com/charmbracelet/crush/internal/app"
  19. "github.com/charmbracelet/crush/internal/config"
  20. "github.com/charmbracelet/crush/internal/db"
  21. "github.com/charmbracelet/crush/internal/event"
  22. "github.com/charmbracelet/crush/internal/projects"
  23. "github.com/charmbracelet/crush/internal/ui/common"
  24. ui "github.com/charmbracelet/crush/internal/ui/model"
  25. "github.com/charmbracelet/crush/internal/version"
  26. uv "github.com/charmbracelet/ultraviolet"
  27. "github.com/charmbracelet/x/ansi"
  28. "github.com/charmbracelet/x/exp/charmtone"
  29. "github.com/charmbracelet/x/term"
  30. "github.com/spf13/cobra"
  31. )
  32. func init() {
  33. rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
  34. rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
  35. rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
  36. rootCmd.Flags().BoolP("help", "h", false, "Help")
  37. rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
  38. rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
  39. rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
  40. rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
  41. rootCmd.AddCommand(
  42. runCmd,
  43. dirsCmd,
  44. projectsCmd,
  45. updateProvidersCmd,
  46. logsCmd,
  47. schemaCmd,
  48. loginCmd,
  49. statsCmd,
  50. sessionCmd,
  51. )
  52. }
  53. var rootCmd = &cobra.Command{
  54. Use: "crush",
  55. Short: "A terminal-first AI assistant for software development",
  56. Long: "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
  57. Example: `
  58. # Run in interactive mode
  59. crush
  60. # Run non-interactively
  61. crush run "Guess my 5 favorite Pokémon"
  62. # Run a non-interactively with pipes and redirection
  63. cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
  64. # Run with debug logging in a specific directory
  65. crush --debug --cwd /path/to/project
  66. # Run in yolo mode (auto-accept all permissions; use with care)
  67. crush --yolo
  68. # Run with custom data directory
  69. crush --data-dir /path/to/custom/.crush
  70. # Continue a previous session
  71. crush --session {session-id}
  72. # Continue the most recent session
  73. crush --continue
  74. `,
  75. RunE: func(cmd *cobra.Command, args []string) error {
  76. sessionID, _ := cmd.Flags().GetString("session")
  77. continueLast, _ := cmd.Flags().GetBool("continue")
  78. app, err := setupAppWithProgressBar(cmd)
  79. if err != nil {
  80. return err
  81. }
  82. defer app.Shutdown()
  83. // Resolve session ID if provided
  84. if sessionID != "" {
  85. sess, err := resolveSessionID(cmd.Context(), app.Sessions, sessionID)
  86. if err != nil {
  87. return err
  88. }
  89. sessionID = sess.ID
  90. }
  91. event.AppInitialized()
  92. // Set up the TUI.
  93. var env uv.Environ = os.Environ()
  94. com := common.DefaultCommon(app)
  95. model := ui.New(com, sessionID, continueLast)
  96. program := tea.NewProgram(
  97. model,
  98. tea.WithEnvironment(env),
  99. tea.WithContext(cmd.Context()),
  100. tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
  101. )
  102. go app.Subscribe(program)
  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. }
  111. var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
  112. ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
  113. ███████████ ███████████
  114. ████████████████████████████
  115. ████████████████████████████
  116. ██████████▀██████▀██████████
  117. ██████████ ██████ ██████████
  118. ▀▀██████▄████▄▄████▄██████▀▀
  119. ████████████████████████
  120. ████████████████████
  121. ▀▀██████████▀▀
  122. ▀▀▀▀▀▀
  123. `)
  124. // copied from cobra:
  125. const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
  126. `
  127. func 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. // supportsProgressBar tries to determine whether the current terminal supports
  152. // progress bars by looking into environment variables.
  153. func supportsProgressBar() bool {
  154. if !term.IsTerminal(os.Stderr.Fd()) {
  155. return false
  156. }
  157. termProg := os.Getenv("TERM_PROGRAM")
  158. _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
  159. return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
  160. }
  161. func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
  162. app, err := setupApp(cmd)
  163. if err != nil {
  164. return nil, err
  165. }
  166. // Check if progress bar is enabled in config (defaults to true if nil)
  167. progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
  168. if progressEnabled && supportsProgressBar() {
  169. _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
  170. defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
  171. }
  172. return app, nil
  173. }
  174. // setupApp handles the common setup logic for both interactive and non-interactive modes.
  175. // It returns the app instance, config, cleanup function, and any error.
  176. func setupApp(cmd *cobra.Command) (*app.App, error) {
  177. debug, _ := cmd.Flags().GetBool("debug")
  178. yolo, _ := cmd.Flags().GetBool("yolo")
  179. dataDir, _ := cmd.Flags().GetString("data-dir")
  180. ctx := cmd.Context()
  181. cwd, err := ResolveCwd(cmd)
  182. if err != nil {
  183. return nil, err
  184. }
  185. store, err := config.Init(cwd, dataDir, debug)
  186. if err != nil {
  187. return nil, err
  188. }
  189. cfg := store.Config()
  190. if cfg.Permissions == nil {
  191. cfg.Permissions = &config.Permissions{}
  192. }
  193. cfg.Permissions.SkipRequests = yolo
  194. if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
  195. return nil, err
  196. }
  197. // Register this project in the centralized projects list.
  198. if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
  199. slog.Warn("Failed to register project", "error", err)
  200. // Non-fatal: continue even if registration fails
  201. }
  202. // Connect to DB; this will also run migrations.
  203. conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
  204. if err != nil {
  205. return nil, err
  206. }
  207. appInstance, err := app.New(ctx, conn, store)
  208. if err != nil {
  209. slog.Error("Failed to create app instance", "error", err)
  210. return nil, err
  211. }
  212. if shouldEnableMetrics(cfg) {
  213. event.Init()
  214. }
  215. return appInstance, nil
  216. }
  217. func shouldEnableMetrics(cfg *config.Config) bool {
  218. if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
  219. return false
  220. }
  221. if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
  222. return false
  223. }
  224. if cfg.Options.DisableMetrics {
  225. return false
  226. }
  227. return true
  228. }
  229. func MaybePrependStdin(prompt string) (string, error) {
  230. if term.IsTerminal(os.Stdin.Fd()) {
  231. return prompt, nil
  232. }
  233. fi, err := os.Stdin.Stat()
  234. if err != nil {
  235. return prompt, err
  236. }
  237. // Check if stdin is a named pipe ( | ) or regular file ( < ).
  238. if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
  239. return prompt, nil
  240. }
  241. bts, err := io.ReadAll(os.Stdin)
  242. if err != nil {
  243. return prompt, err
  244. }
  245. return string(bts) + "\n\n" + prompt, nil
  246. }
  247. func ResolveCwd(cmd *cobra.Command) (string, error) {
  248. cwd, _ := cmd.Flags().GetString("cwd")
  249. if cwd != "" {
  250. err := os.Chdir(cwd)
  251. if err != nil {
  252. return "", fmt.Errorf("failed to change directory: %v", err)
  253. }
  254. return cwd, nil
  255. }
  256. cwd, err := os.Getwd()
  257. if err != nil {
  258. return "", fmt.Errorf("failed to get current working directory: %v", err)
  259. }
  260. return cwd, nil
  261. }
  262. func createDotCrushDir(dir string) error {
  263. if err := os.MkdirAll(dir, 0o700); err != nil {
  264. return fmt.Errorf("failed to create data directory: %q %w", dir, err)
  265. }
  266. gitIgnorePath := filepath.Join(dir, ".gitignore")
  267. content, err := os.ReadFile(gitIgnorePath)
  268. // create or update if old version
  269. if os.IsNotExist(err) || string(content) == oldGitIgnore {
  270. if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
  271. return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
  272. }
  273. }
  274. return nil
  275. }
  276. //go:embed gitignore/old
  277. var oldGitIgnore string
  278. //go:embed gitignore/default
  279. var defaultGitIgnore string