| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619 |
- package cmd
- import (
- "bytes"
- "context"
- _ "embed"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "log/slog"
- "net/url"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "strconv"
- "strings"
- "time"
- tea "charm.land/bubbletea/v2"
- fang "charm.land/fang/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/colorprofile"
- "github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/client"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/db"
- "github.com/charmbracelet/crush/internal/event"
- crushlog "github.com/charmbracelet/crush/internal/log"
- "github.com/charmbracelet/crush/internal/projects"
- "github.com/charmbracelet/crush/internal/proto"
- "github.com/charmbracelet/crush/internal/server"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/ui/common"
- ui "github.com/charmbracelet/crush/internal/ui/model"
- "github.com/charmbracelet/crush/internal/version"
- "github.com/charmbracelet/crush/internal/workspace"
- uv "github.com/charmbracelet/ultraviolet"
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/exp/charmtone"
- "github.com/charmbracelet/x/term"
- "github.com/spf13/cobra"
- )
- var clientHost string
- func init() {
- rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
- rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
- rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
- rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
- rootCmd.Flags().BoolP("help", "h", false, "Help")
- rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
- rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
- rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
- rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
- rootCmd.AddCommand(
- runCmd,
- dirsCmd,
- projectsCmd,
- updateProvidersCmd,
- logsCmd,
- schemaCmd,
- loginCmd,
- statsCmd,
- sessionCmd,
- )
- }
- var rootCmd = &cobra.Command{
- Use: "crush",
- Short: "A terminal-first AI assistant for software development",
- Long: "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
- Example: `
- # Run in interactive mode
- crush
- # Run non-interactively
- crush run "Guess my 5 favorite Pokémon"
- # Run a non-interactively with pipes and redirection
- cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
- # Run with debug logging in a specific directory
- crush --debug --cwd /path/to/project
- # Run in yolo mode (auto-accept all permissions; use with care)
- crush --yolo
- # Run with custom data directory
- crush --data-dir /path/to/custom/.crush
- # Continue a previous session
- crush --session {session-id}
- # Continue the most recent session
- crush --continue
- `,
- RunE: func(cmd *cobra.Command, args []string) error {
- sessionID, _ := cmd.Flags().GetString("session")
- continueLast, _ := cmd.Flags().GetBool("continue")
- ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
- if err != nil {
- return err
- }
- defer cleanup()
- if sessionID != "" {
- sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
- if err != nil {
- return err
- }
- sessionID = sess.ID
- }
- event.AppInitialized()
- com := common.DefaultCommon(ws)
- model := ui.New(com, sessionID, continueLast)
- var env uv.Environ = os.Environ()
- program := tea.NewProgram(
- model,
- tea.WithEnvironment(env),
- tea.WithContext(cmd.Context()),
- tea.WithFilter(ui.MouseEventFilter),
- )
- go ws.Subscribe(program)
- if _, err := program.Run(); err != nil {
- event.Error(err)
- slog.Error("TUI run error", "error", err)
- 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
- }
- return nil
- },
- }
- var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
- ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
- ███████████ ███████████
- ████████████████████████████
- ████████████████████████████
- ██████████▀██████▀██████████
- ██████████ ██████ ██████████
- ▀▀██████▄████▄▄████▄██████▀▀
- ████████████████████████
- ████████████████████
- ▀▀██████████▀▀
- ▀▀▀▀▀▀
- `)
- // copied from cobra:
- const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
- `
- func Execute() {
- // FIXME: config.Load uses slog internally during provider resolution,
- // but the file-based logger isn't set up until after config is loaded
- // (because the log path depends on the data directory from config).
- // This creates a window where slog calls in config.Load leak to
- // stderr. We discard early logs here as a workaround. The proper
- // fix is to remove slog calls from config.Load and have it return
- // warnings/diagnostics instead of logging them as a side effect.
- slog.SetDefault(slog.New(slog.DiscardHandler))
- // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
- // it forward to a bytes.Buffer, write the colored heartbit to it, and then
- // finally prepend it in the version template.
- // Unfortunately cobra doesn't give us a way to set a function to handle
- // printing the version, and PreRunE runs after the version is already
- // handled, so that doesn't work either.
- // This is the only way I could find that works relatively well.
- if term.IsTerminal(os.Stdout.Fd()) {
- var b bytes.Buffer
- w := colorprofile.NewWriter(os.Stdout, os.Environ())
- w.Forward = &b
- _, _ = w.WriteString(heartbit.String())
- rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
- }
- if err := fang.Execute(
- context.Background(),
- rootCmd,
- fang.WithVersion(version.Version),
- fang.WithNotifySignal(os.Interrupt),
- ); err != nil {
- os.Exit(1)
- }
- }
- // supportsProgressBar tries to determine whether the current terminal supports
- // progress bars by looking into environment variables.
- func supportsProgressBar() bool {
- if !term.IsTerminal(os.Stderr.Fd()) {
- return false
- }
- termProg := os.Getenv("TERM_PROGRAM")
- _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
- return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
- }
- // useClientServer returns true when the client/server architecture is
- // enabled via the CRUSH_CLIENT_SERVER environment variable.
- func useClientServer() bool {
- v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
- return v
- }
- // setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
- // terminal progress bar shown during initialization.
- func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
- showProgress := supportsProgressBar()
- if showProgress {
- _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
- }
- ws, cleanup, err := setupWorkspace(cmd)
- if showProgress {
- _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
- }
- return ws, cleanup, err
- }
- // setupWorkspace returns a Workspace and cleanup function. When
- // CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
- // ClientWorkspace. Otherwise it creates an in-process app.App and
- // returns an AppWorkspace.
- func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
- if useClientServer() {
- return setupClientServerWorkspace(cmd)
- }
- return setupLocalWorkspace(cmd)
- }
- // setupLocalWorkspace creates an in-process app.App and wraps it in an
- // AppWorkspace.
- func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
- debug, _ := cmd.Flags().GetBool("debug")
- yolo, _ := cmd.Flags().GetBool("yolo")
- dataDir, _ := cmd.Flags().GetString("data-dir")
- ctx := cmd.Context()
- cwd, err := ResolveCwd(cmd)
- if err != nil {
- return nil, nil, err
- }
- store, err := config.Init(cwd, dataDir, debug)
- if err != nil {
- return nil, nil, err
- }
- cfg := store.Config()
- store.Overrides().SkipPermissionRequests = yolo
- if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
- return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
- }
- gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
- if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
- if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
- return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
- }
- }
- if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
- slog.Warn("Failed to register project", "error", err)
- }
- conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
- if err != nil {
- return nil, nil, err
- }
- logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
- crushlog.Setup(logFile, debug)
- appInstance, err := app.New(ctx, conn, store)
- if err != nil {
- _ = conn.Close()
- slog.Error("Failed to create app instance", "error", err)
- return nil, nil, err
- }
- if shouldEnableMetrics(cfg) {
- event.Init()
- }
- ws := workspace.NewAppWorkspace(appInstance, store)
- cleanup := func() { appInstance.Shutdown() }
- return ws, cleanup, nil
- }
- // setupClientServerWorkspace connects to a server process and wraps the
- // result in a ClientWorkspace.
- func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
- c, protoWs, cleanupServer, err := connectToServer(cmd)
- if err != nil {
- return nil, nil, err
- }
- clientWs := workspace.NewClientWorkspace(c, *protoWs)
- if protoWs.Config.IsConfigured() {
- if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
- slog.Error("Failed to initialize coder agent", "error", err)
- }
- }
- return clientWs, cleanupServer, nil
- }
- // connectToServer ensures the server is running, creates a client and
- // workspace, and returns a cleanup function that deletes the workspace.
- func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
- hostURL, err := server.ParseHostURL(clientHost)
- if err != nil {
- return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
- }
- if err := ensureServer(cmd, hostURL); err != nil {
- return nil, nil, nil, err
- }
- debug, _ := cmd.Flags().GetBool("debug")
- yolo, _ := cmd.Flags().GetBool("yolo")
- dataDir, _ := cmd.Flags().GetString("data-dir")
- ctx := cmd.Context()
- cwd, err := ResolveCwd(cmd)
- if err != nil {
- return nil, nil, nil, err
- }
- c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
- if err != nil {
- return nil, nil, nil, err
- }
- wsReq := proto.Workspace{
- Path: cwd,
- DataDir: dataDir,
- Debug: debug,
- YOLO: yolo,
- Version: version.Version,
- Env: os.Environ(),
- }
- ws, err := c.CreateWorkspace(ctx, wsReq)
- if err != nil {
- // The server socket may exist before the HTTP handler is ready.
- // Retry a few times with a short backoff.
- for range 5 {
- select {
- case <-ctx.Done():
- return nil, nil, nil, ctx.Err()
- case <-time.After(200 * time.Millisecond):
- }
- ws, err = c.CreateWorkspace(ctx, wsReq)
- if err == nil {
- break
- }
- }
- if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
- }
- }
- if shouldEnableMetrics(ws.Config) {
- event.Init()
- }
- if ws.Config != nil {
- logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
- crushlog.Setup(logFile, debug)
- }
- cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
- return c, ws, cleanup, nil
- }
- // ensureServer auto-starts a detached server if the socket file does not
- // exist. When the socket exists, it verifies that the running server
- // version matches the client; on mismatch it shuts down the old server
- // and starts a fresh one.
- func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
- switch hostURL.Scheme {
- case "unix", "npipe":
- needsStart := false
- if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
- needsStart = true
- } else if err == nil {
- if err := restartIfStale(cmd, hostURL); err != nil {
- slog.Warn("Failed to check server version, restarting", "error", err)
- needsStart = true
- }
- }
- if needsStart {
- if err := startDetachedServer(cmd); err != nil {
- return err
- }
- }
- var err error
- for range 10 {
- _, err = os.Stat(hostURL.Host)
- if err == nil {
- break
- }
- select {
- case <-cmd.Context().Done():
- return cmd.Context().Err()
- case <-time.After(100 * time.Millisecond):
- }
- }
- if err != nil {
- return fmt.Errorf("failed to initialize crush server: %v", err)
- }
- }
- return nil
- }
- // restartIfStale checks whether the running server matches the current
- // client version. When they differ, it sends a shutdown command and
- // removes the stale socket so the caller can start a fresh server.
- func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
- c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
- if err != nil {
- return err
- }
- vi, err := c.VersionInfo(cmd.Context())
- if err != nil {
- return err
- }
- if vi.Version == version.Version {
- return nil
- }
- slog.Info("Server version mismatch, restarting",
- "server", vi.Version,
- "client", version.Version,
- )
- _ = c.ShutdownServer(cmd.Context())
- // Give the old process a moment to release the socket.
- for range 20 {
- if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
- break
- }
- select {
- case <-cmd.Context().Done():
- return cmd.Context().Err()
- case <-time.After(100 * time.Millisecond):
- }
- }
- // Force-remove if the socket is still lingering.
- _ = os.Remove(hostURL.Host)
- return nil
- }
- var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
- func startDetachedServer(cmd *cobra.Command) error {
- exe, err := os.Executable()
- if err != nil {
- return fmt.Errorf("failed to get executable path: %v", err)
- }
- safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
- chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
- if err := os.MkdirAll(chDir, 0o700); err != nil {
- return fmt.Errorf("failed to create server working directory: %v", err)
- }
- cmdArgs := []string{"server"}
- if clientHost != server.DefaultHost() {
- cmdArgs = append(cmdArgs, "--host", clientHost)
- }
- c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
- stdoutPath := filepath.Join(chDir, "stdout.log")
- stderrPath := filepath.Join(chDir, "stderr.log")
- detachProcess(c)
- stdout, err := os.Create(stdoutPath)
- if err != nil {
- return fmt.Errorf("failed to create stdout log file: %v", err)
- }
- defer stdout.Close()
- c.Stdout = stdout
- stderr, err := os.Create(stderrPath)
- if err != nil {
- return fmt.Errorf("failed to create stderr log file: %v", err)
- }
- defer stderr.Close()
- c.Stderr = stderr
- if err := c.Start(); err != nil {
- return fmt.Errorf("failed to start crush server: %v", err)
- }
- if err := c.Process.Release(); err != nil {
- return fmt.Errorf("failed to detach crush server process: %v", err)
- }
- return nil
- }
- func shouldEnableMetrics(cfg *config.Config) bool {
- if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
- return false
- }
- if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
- return false
- }
- if cfg.Options.DisableMetrics {
- return false
- }
- return true
- }
- func MaybePrependStdin(prompt string) (string, error) {
- if term.IsTerminal(os.Stdin.Fd()) {
- return prompt, nil
- }
- fi, err := os.Stdin.Stat()
- if err != nil {
- return prompt, err
- }
- // Check if stdin is a named pipe ( | ) or regular file ( < ).
- if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
- return prompt, nil
- }
- bts, err := io.ReadAll(os.Stdin)
- if err != nil {
- return prompt, err
- }
- return string(bts) + "\n\n" + prompt, nil
- }
- // resolveWorkspaceSessionID resolves a session ID that may be a full
- // UUID, full hash, or hash prefix. Works against the Workspace
- // interface so both local and client/server paths get hash prefix
- // support.
- func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
- if sess, err := ws.GetSession(ctx, id); err == nil {
- return sess, nil
- }
- sessions, err := ws.ListSessions(ctx)
- if err != nil {
- return session.Session{}, err
- }
- var matches []session.Session
- for _, s := range sessions {
- hash := session.HashID(s.ID)
- if hash == id || strings.HasPrefix(hash, id) {
- matches = append(matches, s)
- }
- }
- switch len(matches) {
- case 0:
- return session.Session{}, fmt.Errorf("session not found: %s", id)
- case 1:
- return matches[0], nil
- default:
- return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
- }
- }
- func ResolveCwd(cmd *cobra.Command) (string, error) {
- cwd, _ := cmd.Flags().GetString("cwd")
- if cwd != "" {
- err := os.Chdir(cwd)
- if err != nil {
- return "", fmt.Errorf("failed to change directory: %v", err)
- }
- return cwd, nil
- }
- cwd, err := os.Getwd()
- if err != nil {
- return "", fmt.Errorf("failed to get current working directory: %v", err)
- }
- return cwd, nil
- }
- func createDotCrushDir(dir string) error {
- if err := os.MkdirAll(dir, 0o700); err != nil {
- return fmt.Errorf("failed to create data directory: %q %w", dir, err)
- }
- gitIgnorePath := filepath.Join(dir, ".gitignore")
- content, err := os.ReadFile(gitIgnorePath)
- // create or update if old version
- if os.IsNotExist(err) || string(content) == oldGitIgnore {
- if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
- return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
- }
- }
- return nil
- }
- //go:embed gitignore/old
- var oldGitIgnore string
- //go:embed gitignore/default
- var defaultGitIgnore string
|