root.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. package cmd
  2. import (
  3. "bytes"
  4. "context"
  5. _ "embed"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "io/fs"
  10. "log/slog"
  11. "net/url"
  12. "os"
  13. "os/exec"
  14. "path/filepath"
  15. "regexp"
  16. "strconv"
  17. "strings"
  18. "time"
  19. tea "charm.land/bubbletea/v2"
  20. fang "charm.land/fang/v2"
  21. "charm.land/lipgloss/v2"
  22. "github.com/charmbracelet/colorprofile"
  23. "github.com/charmbracelet/crush/internal/app"
  24. "github.com/charmbracelet/crush/internal/client"
  25. "github.com/charmbracelet/crush/internal/config"
  26. "github.com/charmbracelet/crush/internal/db"
  27. "github.com/charmbracelet/crush/internal/event"
  28. crushlog "github.com/charmbracelet/crush/internal/log"
  29. "github.com/charmbracelet/crush/internal/projects"
  30. "github.com/charmbracelet/crush/internal/proto"
  31. "github.com/charmbracelet/crush/internal/server"
  32. "github.com/charmbracelet/crush/internal/session"
  33. "github.com/charmbracelet/crush/internal/ui/common"
  34. ui "github.com/charmbracelet/crush/internal/ui/model"
  35. "github.com/charmbracelet/crush/internal/version"
  36. "github.com/charmbracelet/crush/internal/workspace"
  37. uv "github.com/charmbracelet/ultraviolet"
  38. "github.com/charmbracelet/x/ansi"
  39. "github.com/charmbracelet/x/exp/charmtone"
  40. "github.com/charmbracelet/x/term"
  41. "github.com/spf13/cobra"
  42. )
  43. var clientHost string
  44. func init() {
  45. rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
  46. rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
  47. rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
  48. rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
  49. rootCmd.Flags().BoolP("help", "h", false, "Help")
  50. rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
  51. rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
  52. rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
  53. rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
  54. rootCmd.AddCommand(
  55. runCmd,
  56. dirsCmd,
  57. projectsCmd,
  58. updateProvidersCmd,
  59. logsCmd,
  60. schemaCmd,
  61. loginCmd,
  62. statsCmd,
  63. sessionCmd,
  64. )
  65. }
  66. var rootCmd = &cobra.Command{
  67. Use: "crush",
  68. Short: "A terminal-first AI assistant for software development",
  69. Long: "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
  70. Example: `
  71. # Run in interactive mode
  72. crush
  73. # Run non-interactively
  74. crush run "Guess my 5 favorite Pokémon"
  75. # Run a non-interactively with pipes and redirection
  76. cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
  77. # Run with debug logging in a specific directory
  78. crush --debug --cwd /path/to/project
  79. # Run in yolo mode (auto-accept all permissions; use with care)
  80. crush --yolo
  81. # Run with custom data directory
  82. crush --data-dir /path/to/custom/.crush
  83. # Continue a previous session
  84. crush --session {session-id}
  85. # Continue the most recent session
  86. crush --continue
  87. `,
  88. RunE: func(cmd *cobra.Command, args []string) error {
  89. sessionID, _ := cmd.Flags().GetString("session")
  90. continueLast, _ := cmd.Flags().GetBool("continue")
  91. ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
  92. if err != nil {
  93. return err
  94. }
  95. defer cleanup()
  96. if sessionID != "" {
  97. sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
  98. if err != nil {
  99. return err
  100. }
  101. sessionID = sess.ID
  102. }
  103. event.AppInitialized()
  104. com := common.DefaultCommon(ws)
  105. model := ui.New(com, sessionID, continueLast)
  106. var env uv.Environ = os.Environ()
  107. program := tea.NewProgram(
  108. model,
  109. tea.WithEnvironment(env),
  110. tea.WithContext(cmd.Context()),
  111. tea.WithFilter(ui.MouseEventFilter),
  112. )
  113. go ws.Subscribe(program)
  114. if _, err := program.Run(); err != nil {
  115. event.Error(err)
  116. slog.Error("TUI run error", "error", err)
  117. 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
  118. }
  119. return nil
  120. },
  121. }
  122. var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
  123. ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
  124. ███████████ ███████████
  125. ████████████████████████████
  126. ████████████████████████████
  127. ██████████▀██████▀██████████
  128. ██████████ ██████ ██████████
  129. ▀▀██████▄████▄▄████▄██████▀▀
  130. ████████████████████████
  131. ████████████████████
  132. ▀▀██████████▀▀
  133. ▀▀▀▀▀▀
  134. `)
  135. // copied from cobra:
  136. const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
  137. `
  138. func Execute() {
  139. // FIXME: config.Load uses slog internally during provider resolution,
  140. // but the file-based logger isn't set up until after config is loaded
  141. // (because the log path depends on the data directory from config).
  142. // This creates a window where slog calls in config.Load leak to
  143. // stderr. We discard early logs here as a workaround. The proper
  144. // fix is to remove slog calls from config.Load and have it return
  145. // warnings/diagnostics instead of logging them as a side effect.
  146. slog.SetDefault(slog.New(slog.DiscardHandler))
  147. // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
  148. // it forward to a bytes.Buffer, write the colored heartbit to it, and then
  149. // finally prepend it in the version template.
  150. // Unfortunately cobra doesn't give us a way to set a function to handle
  151. // printing the version, and PreRunE runs after the version is already
  152. // handled, so that doesn't work either.
  153. // This is the only way I could find that works relatively well.
  154. if term.IsTerminal(os.Stdout.Fd()) {
  155. var b bytes.Buffer
  156. w := colorprofile.NewWriter(os.Stdout, os.Environ())
  157. w.Forward = &b
  158. _, _ = w.WriteString(heartbit.String())
  159. rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
  160. }
  161. if err := fang.Execute(
  162. context.Background(),
  163. rootCmd,
  164. fang.WithVersion(version.Version),
  165. fang.WithNotifySignal(os.Interrupt),
  166. ); err != nil {
  167. os.Exit(1)
  168. }
  169. }
  170. // supportsProgressBar tries to determine whether the current terminal supports
  171. // progress bars by looking into environment variables.
  172. func supportsProgressBar() bool {
  173. if !term.IsTerminal(os.Stderr.Fd()) {
  174. return false
  175. }
  176. termProg := os.Getenv("TERM_PROGRAM")
  177. _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
  178. return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
  179. }
  180. // useClientServer returns true when the client/server architecture is
  181. // enabled via the CRUSH_CLIENT_SERVER environment variable.
  182. func useClientServer() bool {
  183. v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
  184. return v
  185. }
  186. // setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
  187. // terminal progress bar shown during initialization.
  188. func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
  189. showProgress := supportsProgressBar()
  190. if showProgress {
  191. _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
  192. }
  193. ws, cleanup, err := setupWorkspace(cmd)
  194. if showProgress {
  195. _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
  196. }
  197. return ws, cleanup, err
  198. }
  199. // setupWorkspace returns a Workspace and cleanup function. When
  200. // CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
  201. // ClientWorkspace. Otherwise it creates an in-process app.App and
  202. // returns an AppWorkspace.
  203. func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
  204. if useClientServer() {
  205. return setupClientServerWorkspace(cmd)
  206. }
  207. return setupLocalWorkspace(cmd)
  208. }
  209. // setupLocalWorkspace creates an in-process app.App and wraps it in an
  210. // AppWorkspace.
  211. func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
  212. debug, _ := cmd.Flags().GetBool("debug")
  213. yolo, _ := cmd.Flags().GetBool("yolo")
  214. dataDir, _ := cmd.Flags().GetString("data-dir")
  215. ctx := cmd.Context()
  216. cwd, err := ResolveCwd(cmd)
  217. if err != nil {
  218. return nil, nil, err
  219. }
  220. store, err := config.Init(cwd, dataDir, debug)
  221. if err != nil {
  222. return nil, nil, err
  223. }
  224. cfg := store.Config()
  225. store.Overrides().SkipPermissionRequests = yolo
  226. if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
  227. return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
  228. }
  229. gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
  230. if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
  231. if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
  232. return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
  233. }
  234. }
  235. if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
  236. slog.Warn("Failed to register project", "error", err)
  237. }
  238. conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
  239. if err != nil {
  240. return nil, nil, err
  241. }
  242. logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
  243. crushlog.Setup(logFile, debug)
  244. appInstance, err := app.New(ctx, conn, store)
  245. if err != nil {
  246. _ = conn.Close()
  247. slog.Error("Failed to create app instance", "error", err)
  248. return nil, nil, err
  249. }
  250. if shouldEnableMetrics(cfg) {
  251. event.Init()
  252. }
  253. ws := workspace.NewAppWorkspace(appInstance, store)
  254. cleanup := func() { appInstance.Shutdown() }
  255. return ws, cleanup, nil
  256. }
  257. // setupClientServerWorkspace connects to a server process and wraps the
  258. // result in a ClientWorkspace.
  259. func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
  260. c, protoWs, cleanupServer, err := connectToServer(cmd)
  261. if err != nil {
  262. return nil, nil, err
  263. }
  264. clientWs := workspace.NewClientWorkspace(c, *protoWs)
  265. if protoWs.Config.IsConfigured() {
  266. if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
  267. slog.Error("Failed to initialize coder agent", "error", err)
  268. }
  269. }
  270. return clientWs, cleanupServer, nil
  271. }
  272. // connectToServer ensures the server is running, creates a client and
  273. // workspace, and returns a cleanup function that deletes the workspace.
  274. func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
  275. hostURL, err := server.ParseHostURL(clientHost)
  276. if err != nil {
  277. return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
  278. }
  279. if err := ensureServer(cmd, hostURL); err != nil {
  280. return nil, nil, nil, err
  281. }
  282. debug, _ := cmd.Flags().GetBool("debug")
  283. yolo, _ := cmd.Flags().GetBool("yolo")
  284. dataDir, _ := cmd.Flags().GetString("data-dir")
  285. ctx := cmd.Context()
  286. cwd, err := ResolveCwd(cmd)
  287. if err != nil {
  288. return nil, nil, nil, err
  289. }
  290. c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
  291. if err != nil {
  292. return nil, nil, nil, err
  293. }
  294. wsReq := proto.Workspace{
  295. Path: cwd,
  296. DataDir: dataDir,
  297. Debug: debug,
  298. YOLO: yolo,
  299. Version: version.Version,
  300. Env: os.Environ(),
  301. }
  302. ws, err := c.CreateWorkspace(ctx, wsReq)
  303. if err != nil {
  304. // The server socket may exist before the HTTP handler is ready.
  305. // Retry a few times with a short backoff.
  306. for range 5 {
  307. select {
  308. case <-ctx.Done():
  309. return nil, nil, nil, ctx.Err()
  310. case <-time.After(200 * time.Millisecond):
  311. }
  312. ws, err = c.CreateWorkspace(ctx, wsReq)
  313. if err == nil {
  314. break
  315. }
  316. }
  317. if err != nil {
  318. return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
  319. }
  320. }
  321. if shouldEnableMetrics(ws.Config) {
  322. event.Init()
  323. }
  324. if ws.Config != nil {
  325. logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
  326. crushlog.Setup(logFile, debug)
  327. }
  328. cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
  329. return c, ws, cleanup, nil
  330. }
  331. // ensureServer auto-starts a detached server if the socket file does not
  332. // exist. When the socket exists, it verifies that the running server
  333. // version matches the client; on mismatch it shuts down the old server
  334. // and starts a fresh one.
  335. func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
  336. switch hostURL.Scheme {
  337. case "unix", "npipe":
  338. needsStart := false
  339. if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
  340. needsStart = true
  341. } else if err == nil {
  342. if err := restartIfStale(cmd, hostURL); err != nil {
  343. slog.Warn("Failed to check server version, restarting", "error", err)
  344. needsStart = true
  345. }
  346. }
  347. if needsStart {
  348. if err := startDetachedServer(cmd); err != nil {
  349. return err
  350. }
  351. }
  352. var err error
  353. for range 10 {
  354. _, err = os.Stat(hostURL.Host)
  355. if err == nil {
  356. break
  357. }
  358. select {
  359. case <-cmd.Context().Done():
  360. return cmd.Context().Err()
  361. case <-time.After(100 * time.Millisecond):
  362. }
  363. }
  364. if err != nil {
  365. return fmt.Errorf("failed to initialize crush server: %v", err)
  366. }
  367. }
  368. return nil
  369. }
  370. // restartIfStale checks whether the running server matches the current
  371. // client version. When they differ, it sends a shutdown command and
  372. // removes the stale socket so the caller can start a fresh server.
  373. func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
  374. c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
  375. if err != nil {
  376. return err
  377. }
  378. vi, err := c.VersionInfo(cmd.Context())
  379. if err != nil {
  380. return err
  381. }
  382. if vi.Version == version.Version {
  383. return nil
  384. }
  385. slog.Info("Server version mismatch, restarting",
  386. "server", vi.Version,
  387. "client", version.Version,
  388. )
  389. _ = c.ShutdownServer(cmd.Context())
  390. // Give the old process a moment to release the socket.
  391. for range 20 {
  392. if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
  393. break
  394. }
  395. select {
  396. case <-cmd.Context().Done():
  397. return cmd.Context().Err()
  398. case <-time.After(100 * time.Millisecond):
  399. }
  400. }
  401. // Force-remove if the socket is still lingering.
  402. _ = os.Remove(hostURL.Host)
  403. return nil
  404. }
  405. var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
  406. func startDetachedServer(cmd *cobra.Command) error {
  407. exe, err := os.Executable()
  408. if err != nil {
  409. return fmt.Errorf("failed to get executable path: %v", err)
  410. }
  411. safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
  412. chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
  413. if err := os.MkdirAll(chDir, 0o700); err != nil {
  414. return fmt.Errorf("failed to create server working directory: %v", err)
  415. }
  416. cmdArgs := []string{"server"}
  417. if clientHost != server.DefaultHost() {
  418. cmdArgs = append(cmdArgs, "--host", clientHost)
  419. }
  420. c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
  421. stdoutPath := filepath.Join(chDir, "stdout.log")
  422. stderrPath := filepath.Join(chDir, "stderr.log")
  423. detachProcess(c)
  424. stdout, err := os.Create(stdoutPath)
  425. if err != nil {
  426. return fmt.Errorf("failed to create stdout log file: %v", err)
  427. }
  428. defer stdout.Close()
  429. c.Stdout = stdout
  430. stderr, err := os.Create(stderrPath)
  431. if err != nil {
  432. return fmt.Errorf("failed to create stderr log file: %v", err)
  433. }
  434. defer stderr.Close()
  435. c.Stderr = stderr
  436. if err := c.Start(); err != nil {
  437. return fmt.Errorf("failed to start crush server: %v", err)
  438. }
  439. if err := c.Process.Release(); err != nil {
  440. return fmt.Errorf("failed to detach crush server process: %v", err)
  441. }
  442. return nil
  443. }
  444. func shouldEnableMetrics(cfg *config.Config) bool {
  445. if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
  446. return false
  447. }
  448. if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
  449. return false
  450. }
  451. if cfg.Options.DisableMetrics {
  452. return false
  453. }
  454. return true
  455. }
  456. func MaybePrependStdin(prompt string) (string, error) {
  457. if term.IsTerminal(os.Stdin.Fd()) {
  458. return prompt, nil
  459. }
  460. fi, err := os.Stdin.Stat()
  461. if err != nil {
  462. return prompt, err
  463. }
  464. // Check if stdin is a named pipe ( | ) or regular file ( < ).
  465. if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
  466. return prompt, nil
  467. }
  468. bts, err := io.ReadAll(os.Stdin)
  469. if err != nil {
  470. return prompt, err
  471. }
  472. return string(bts) + "\n\n" + prompt, nil
  473. }
  474. // resolveWorkspaceSessionID resolves a session ID that may be a full
  475. // UUID, full hash, or hash prefix. Works against the Workspace
  476. // interface so both local and client/server paths get hash prefix
  477. // support.
  478. func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
  479. if sess, err := ws.GetSession(ctx, id); err == nil {
  480. return sess, nil
  481. }
  482. sessions, err := ws.ListSessions(ctx)
  483. if err != nil {
  484. return session.Session{}, err
  485. }
  486. var matches []session.Session
  487. for _, s := range sessions {
  488. hash := session.HashID(s.ID)
  489. if hash == id || strings.HasPrefix(hash, id) {
  490. matches = append(matches, s)
  491. }
  492. }
  493. switch len(matches) {
  494. case 0:
  495. return session.Session{}, fmt.Errorf("session not found: %s", id)
  496. case 1:
  497. return matches[0], nil
  498. default:
  499. return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
  500. }
  501. }
  502. func ResolveCwd(cmd *cobra.Command) (string, error) {
  503. cwd, _ := cmd.Flags().GetString("cwd")
  504. if cwd != "" {
  505. err := os.Chdir(cwd)
  506. if err != nil {
  507. return "", fmt.Errorf("failed to change directory: %v", err)
  508. }
  509. return cwd, nil
  510. }
  511. cwd, err := os.Getwd()
  512. if err != nil {
  513. return "", fmt.Errorf("failed to get current working directory: %v", err)
  514. }
  515. return cwd, nil
  516. }
  517. func createDotCrushDir(dir string) error {
  518. if err := os.MkdirAll(dir, 0o700); err != nil {
  519. return fmt.Errorf("failed to create data directory: %q %w", dir, err)
  520. }
  521. gitIgnorePath := filepath.Join(dir, ".gitignore")
  522. content, err := os.ReadFile(gitIgnorePath)
  523. // create or update if old version
  524. if os.IsNotExist(err) || string(content) == oldGitIgnore {
  525. if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
  526. return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
  527. }
  528. }
  529. return nil
  530. }
  531. //go:embed gitignore/old
  532. var oldGitIgnore string
  533. //go:embed gitignore/default
  534. var defaultGitIgnore string