21 tea "charm.land/bubbletea/v2"
22 fang "charm.land/fang/v2"
23 "charm.land/lipgloss/v2"
24 "github.com/charmbracelet/colorprofile"
25 "github.com/charmbracelet/crush/internal/app"
26 "github.com/charmbracelet/crush/internal/client"
27 "github.com/charmbracelet/crush/internal/config"
28 "github.com/charmbracelet/crush/internal/db"
29 "github.com/charmbracelet/crush/internal/event"
30 crushlog "github.com/charmbracelet/crush/internal/log"
31 "github.com/charmbracelet/crush/internal/projects"
32 "github.com/charmbracelet/crush/internal/proto"
33 "github.com/charmbracelet/crush/internal/server"
34 "github.com/charmbracelet/crush/internal/session"
35 "github.com/charmbracelet/crush/internal/ui/common"
36 ui "github.com/charmbracelet/crush/internal/ui/model"
37 "github.com/charmbracelet/crush/internal/version"
38 "github.com/charmbracelet/crush/internal/workspace"
39 uv "github.com/charmbracelet/ultraviolet"
40 "github.com/charmbracelet/x/ansi"
41 "github.com/charmbracelet/x/exp/charmtone"
42 xstrings "github.com/charmbracelet/x/exp/strings"
43 "github.com/charmbracelet/x/term"
44 "github.com/spf13/cobra"
50 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
51 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
52 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
53 rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
54 rootCmd.Flags().BoolP("help", "h", false, "Help")
55 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
56 rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
57 rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
58 rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
73var rootCmd = &cobra.Command{
75 Short: "A terminal-first AI assistant for software development",
76 Long: "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
78# Run in interactive mode
81# Run non-interactively
82crush run "Guess my 5 favorite Pokémon"
84# Run a non-interactively with pipes and redirection
85cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
87# Run with debug logging in a specific directory
88crush --debug --cwd /path/to/project
90# Run in yolo mode (auto-accept all permissions; use with care)
93# Run with custom data directory
94crush --data-dir /path/to/custom/.crush
96# Continue a previous session
97crush --session {session-id}
99# Continue the most recent session
102 RunE: func(cmd *cobra.Command, args []string) error {
103 sessionID, _ := cmd.Flags().GetString("session")
104 continueLast, _ := cmd.Flags().GetBool("continue")
106 ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
113 sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
120 event.AppInitialized()
122 com := common.DefaultCommon(ws)
123 model := ui.New(com, sessionID, continueLast)
125 var env uv.Environ = os.Environ()
126 program := tea.NewProgram(
128 tea.WithEnvironment(env),
129 tea.WithContext(cmd.Context()),
130 tea.WithFilter(ui.MouseEventFilter),
132 go ws.Subscribe(program)
134 if _, err := program.Run(); err != nil {
136 slog.Error("TUI run error", "error", err)
137 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
143var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
145 ███████████ ███████████
146████████████████████████████
147████████████████████████████
148██████████▀██████▀██████████
149██████████ ██████ ██████████
150▀▀██████▄████▄▄████▄██████▀▀
151 ████████████████████████
158const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
162 // FIXME: config.Load uses slog internally during provider resolution,
163 // but the file-based logger isn't set up until after config is loaded
164 // (because the log path depends on the data directory from config).
165 // This creates a window where slog calls in config.Load leak to
166 // stderr. We discard early logs here as a workaround. The proper
167 // fix is to remove slog calls from config.Load and have it return
168 // warnings/diagnostics instead of logging them as a side effect.
169 slog.SetDefault(slog.New(slog.DiscardHandler))
171 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
172 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
173 // finally prepend it in the version template.
174 // Unfortunately cobra doesn't give us a way to set a function to handle
175 // printing the version, and PreRunE runs after the version is already
176 // handled, so that doesn't work either.
177 // This is the only way I could find that works relatively well.
178 if term.IsTerminal(os.Stdout.Fd()) {
180 w := colorprofile.NewWriter(os.Stdout, os.Environ())
182 _, _ = w.WriteString(heartbit.String())
183 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
185 if err := fang.Execute(
186 context.Background(),
188 fang.WithVersion(version.Version),
189 fang.WithNotifySignal(os.Interrupt),
195// supportsProgressBar tries to determine whether the current terminal supports
196// progress bars by looking into environment variables.
197func supportsProgressBar() bool {
198 if !term.IsTerminal(os.Stderr.Fd()) {
201 termProg := os.Getenv("TERM_PROGRAM")
202 _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
204 return isWindowsTerminal || xstrings.ContainsAnyOf(strings.ToLower(termProg), "ghostty", "iterm2", "rio")
207// useClientServer returns true when the client/server architecture is
208// enabled via the CRUSH_CLIENT_SERVER environment variable.
209func useClientServer() bool {
210 v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
214// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
215// terminal progress bar shown during initialization.
216func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
217 showProgress := supportsProgressBar()
219 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
222 ws, cleanup, err := setupWorkspace(cmd)
225 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
228 return ws, cleanup, err
231// setupWorkspace returns a Workspace and cleanup function. When
232// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
233// ClientWorkspace. Otherwise it creates an in-process app.App and
234// returns an AppWorkspace.
235func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
236 if useClientServer() {
237 return setupClientServerWorkspace(cmd)
239 return setupLocalWorkspace(cmd)
242// setupLocalWorkspace creates an in-process app.App and wraps it in an
244func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
245 debug, _ := cmd.Flags().GetBool("debug")
246 yolo, _ := cmd.Flags().GetBool("yolo")
247 dataDir, _ := cmd.Flags().GetString("data-dir")
250 cwd, err := ResolveCwd(cmd)
255 store, err := config.Init(cwd, dataDir, debug)
260 cfg := store.Config()
261 store.Overrides().SkipPermissionRequests = yolo
263 if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
264 return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
267 gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
268 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
269 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
270 return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
274 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
275 slog.Warn("Failed to register project", "error", err)
278 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
283 logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
284 crushlog.Setup(logFile, debug)
286 appInstance, err := app.New(ctx, conn, store)
289 slog.Error("Failed to create app instance", "error", err)
293 if shouldEnableMetrics(cfg) {
297 ws := workspace.NewAppWorkspace(appInstance, store)
298 cleanup := func() { appInstance.Shutdown() }
299 return ws, cleanup, nil
302// setupClientServerWorkspace connects to a server process and wraps the
303// result in a ClientWorkspace.
304func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
305 c, protoWs, cleanupServer, err := connectToServer(cmd)
310 clientWs := workspace.NewClientWorkspace(c, *protoWs)
312 if protoWs.Config.IsConfigured() {
313 if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
314 slog.Error("Failed to initialize coder agent", "error", err)
318 return clientWs, cleanupServer, nil
321// connectToServer ensures the server is running, creates a client and
322// workspace, and returns a cleanup function that deletes the workspace.
323func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
324 hostURL, err := server.ParseHostURL(clientHost)
326 return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
329 if err := ensureServer(cmd, hostURL); err != nil {
330 return nil, nil, nil, err
333 debug, _ := cmd.Flags().GetBool("debug")
334 yolo, _ := cmd.Flags().GetBool("yolo")
335 dataDir, _ := cmd.Flags().GetString("data-dir")
338 cwd, err := ResolveCwd(cmd)
340 return nil, nil, nil, err
343 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
345 return nil, nil, nil, err
348 wsReq := proto.Workspace{
353 Version: version.Version,
357 ws, err := c.CreateWorkspace(ctx, wsReq)
359 // The server socket may exist before the HTTP handler is ready.
360 // Retry a few times with a short backoff.
364 return nil, nil, nil, ctx.Err()
365 case <-time.After(200 * time.Millisecond):
367 ws, err = c.CreateWorkspace(ctx, wsReq)
373 return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
377 if shouldEnableMetrics(ws.Config) {
381 if ws.Config != nil {
382 logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
383 crushlog.Setup(logFile, debug)
386 cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
387 return c, ws, cleanup, nil
390// ensureServer auto-starts a detached server if the socket file does not
391// exist. When the socket exists, it verifies that the running server
392// version matches the client; on mismatch it shuts down the old server
393// and starts a fresh one.
394func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
395 switch hostURL.Scheme {
396 case "unix", "npipe":
398 if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
400 } else if err == nil {
401 if err := restartIfStale(cmd, hostURL); err != nil {
402 slog.Warn("Failed to check server version, restarting", "error", err)
408 if err := startDetachedServer(cmd); err != nil {
415 _, err = os.Stat(hostURL.Host)
420 case <-cmd.Context().Done():
421 return cmd.Context().Err()
422 case <-time.After(100 * time.Millisecond):
426 return fmt.Errorf("failed to initialize crush server: %v", err)
433// restartIfStale checks whether the running server matches the current
434// client version. When they differ, it sends a shutdown command and
435// removes the stale socket so the caller can start a fresh server.
436func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
437 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
441 vi, err := c.VersionInfo(cmd.Context())
445 if vi.Version == version.Version {
448 slog.Info("Server version mismatch, restarting",
449 "server", vi.Version,
450 "client", version.Version,
452 _ = c.ShutdownServer(cmd.Context())
453 // Give the old process a moment to release the socket.
455 if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
459 case <-cmd.Context().Done():
460 return cmd.Context().Err()
461 case <-time.After(100 * time.Millisecond):
464 // Force-remove if the socket is still lingering.
465 _ = os.Remove(hostURL.Host)
469var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
471func startDetachedServer(cmd *cobra.Command) error {
472 exe, err := os.Executable()
474 return fmt.Errorf("failed to get executable path: %v", err)
477 safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
478 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
479 if err := os.MkdirAll(chDir, 0o700); err != nil {
480 return fmt.Errorf("failed to create server working directory: %v", err)
483 cmdArgs := []string{"server"}
484 if clientHost != server.DefaultHost() {
485 cmdArgs = append(cmdArgs, "--host", clientHost)
488 c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
489 stdoutPath := filepath.Join(chDir, "stdout.log")
490 stderrPath := filepath.Join(chDir, "stderr.log")
493 stdout, err := os.Create(stdoutPath)
495 return fmt.Errorf("failed to create stdout log file: %v", err)
500 stderr, err := os.Create(stderrPath)
502 return fmt.Errorf("failed to create stderr log file: %v", err)
507 if err := c.Start(); err != nil {
508 return fmt.Errorf("failed to start crush server: %v", err)
511 if err := c.Process.Release(); err != nil {
512 return fmt.Errorf("failed to detach crush server process: %v", err)
518func shouldEnableMetrics(cfg *config.Config) bool {
519 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
522 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
525 if cfg.Options.DisableMetrics {
531func MaybePrependStdin(prompt string) (string, error) {
532 if term.IsTerminal(os.Stdin.Fd()) {
535 fi, err := os.Stdin.Stat()
539 // Check if stdin is a named pipe ( | ) or regular file ( < ).
540 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
543 bts, err := io.ReadAll(os.Stdin)
547 return string(bts) + "\n\n" + prompt, nil
550// resolveWorkspaceSessionID resolves a session ID that may be a full
551// UUID, full hash, or hash prefix. Works against the Workspace
552// interface so both local and client/server paths get hash prefix
554func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
555 if sess, err := ws.GetSession(ctx, id); err == nil {
559 sessions, err := ws.ListSessions(ctx)
561 return session.Session{}, err
564 var matches []session.Session
565 for _, s := range sessions {
566 hash := session.HashID(s.ID)
567 if hash == id || strings.HasPrefix(hash, id) {
568 matches = append(matches, s)
572 switch len(matches) {
574 return session.Session{}, fmt.Errorf("session not found: %s", id)
576 return matches[0], nil
578 return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
582func ResolveCwd(cmd *cobra.Command) (string, error) {
583 cwd, _ := cmd.Flags().GetString("cwd")
587 return "", fmt.Errorf("failed to change directory: %v", err)
591 cwd, err := os.Getwd()
593 return "", fmt.Errorf("failed to get current working directory: %v", err)
598func createDotCrushDir(dir string) error {
599 if err := os.MkdirAll(dir, 0o700); err != nil {
600 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
603 gitIgnorePath := filepath.Join(dir, ".gitignore")
604 content, err := os.ReadFile(gitIgnorePath)
606 // create or update if old version
607 if os.IsNotExist(err) || string(content) == oldGitIgnore {
608 if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
609 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
616//go:embed gitignore/old
617var oldGitIgnore string
619//go:embed gitignore/default
620var defaultGitIgnore string
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list