on 8ebab21 help sign in
crush/internal/cmd/root.go
620 lines 18 kB history raw
1package cmd
3import (
4 "bytes"
5 "context"
6 _ "embed"
7 "errors"
8 "fmt"
9 "io"
10 "io/fs"
11 "log/slog"
12 "net/url"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "regexp"
17 "strconv"
18 "strings"
19 "time"
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"
47var clientHost string
49func init() {
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")
60 rootCmd.AddCommand(
61 runCmd,
62 dirsCmd,
63 projectsCmd,
64 updateProvidersCmd,
65 logsCmd,
66 schemaCmd,
67 loginCmd,
68 statsCmd,
69 sessionCmd,
70 )
73var rootCmd = &cobra.Command{
74 Use: "crush",
75 Short: "A terminal-first AI assistant for software development",
76 Long: "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
77 Example: `
78# Run in interactive mode
79crush
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)
91crush --yolo
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
100crush --continue
101 `,
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)
107 if err != nil {
108 return err
109 }
110 defer cleanup()
112 if sessionID != "" {
113 sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
114 if err != nil {
115 return err
116 }
117 sessionID = sess.ID
118 }
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(
127 model,
128 tea.WithEnvironment(env),
129 tea.WithContext(cmd.Context()),
130 tea.WithFilter(ui.MouseEventFilter),
131 )
132 go ws.Subscribe(program)
134 if _, err := program.Run(); err != nil {
135 event.Error(err)
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
138 }
139 return nil
140 },
143var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
144 ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
145 ███████████ ███████████
146████████████████████████████
147████████████████████████████
148██████████▀██████▀██████████
149██████████ ██████ ██████████
150▀▀██████▄████▄▄████▄██████▀▀
151 ████████████████████████
152 ████████████████████
153 ▀▀██████████▀▀
154 ▀▀▀▀▀▀
155`)
157// copied from cobra:
158const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
161func Execute() {
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()) {
179 var b bytes.Buffer
180 w := colorprofile.NewWriter(os.Stdout, os.Environ())
181 w.Forward = &b
182 _, _ = w.WriteString(heartbit.String())
183 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
184 }
185 if err := fang.Execute(
186 context.Background(),
187 rootCmd,
188 fang.WithVersion(version.Version),
189 fang.WithNotifySignal(os.Interrupt),
190 ); err != nil {
191 os.Exit(1)
192 }
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()) {
199 return false
200 }
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"))
211 return v
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()
218 if showProgress {
219 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
220 }
222 ws, cleanup, err := setupWorkspace(cmd)
224 if showProgress {
225 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
226 }
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)
238 }
239 return setupLocalWorkspace(cmd)
242// setupLocalWorkspace creates an in-process app.App and wraps it in an
243// AppWorkspace.
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")
248 ctx := cmd.Context()
250 cwd, err := ResolveCwd(cmd)
251 if err != nil {
252 return nil, nil, err
253 }
255 store, err := config.Init(cwd, dataDir, debug)
256 if err != nil {
257 return nil, nil, err
258 }
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)
265 }
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)
271 }
272 }
274 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
275 slog.Warn("Failed to register project", "error", err)
276 }
278 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
279 if err != nil {
280 return nil, nil, err
281 }
283 logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
284 crushlog.Setup(logFile, debug)
286 appInstance, err := app.New(ctx, conn, store)
287 if err != nil {
288 _ = conn.Close()
289 slog.Error("Failed to create app instance", "error", err)
290 return nil, nil, err
291 }
293 if shouldEnableMetrics(cfg) {
294 event.Init()
295 }
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)
306 if err != nil {
307 return nil, nil, err
308 }
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)
315 }
316 }
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)
325 if err != nil {
326 return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
327 }
329 if err := ensureServer(cmd, hostURL); err != nil {
330 return nil, nil, nil, err
331 }
333 debug, _ := cmd.Flags().GetBool("debug")
334 yolo, _ := cmd.Flags().GetBool("yolo")
335 dataDir, _ := cmd.Flags().GetString("data-dir")
336 ctx := cmd.Context()
338 cwd, err := ResolveCwd(cmd)
339 if err != nil {
340 return nil, nil, nil, err
341 }
343 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
344 if err != nil {
345 return nil, nil, nil, err
346 }
348 wsReq := proto.Workspace{
349 Path: cwd,
350 DataDir: dataDir,
351 Debug: debug,
352 YOLO: yolo,
353 Version: version.Version,
354 Env: os.Environ(),
355 }
357 ws, err := c.CreateWorkspace(ctx, wsReq)
358 if err != nil {
359 // The server socket may exist before the HTTP handler is ready.
360 // Retry a few times with a short backoff.
361 for range 5 {
362 select {
363 case <-ctx.Done():
364 return nil, nil, nil, ctx.Err()
365 case <-time.After(200 * time.Millisecond):
366 }
367 ws, err = c.CreateWorkspace(ctx, wsReq)
368 if err == nil {
369 break
370 }
371 }
372 if err != nil {
373 return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
374 }
375 }
377 if shouldEnableMetrics(ws.Config) {
378 event.Init()
379 }
381 if ws.Config != nil {
382 logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
383 crushlog.Setup(logFile, debug)
384 }
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":
397 needsStart := false
398 if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
399 needsStart = true
400 } else if err == nil {
401 if err := restartIfStale(cmd, hostURL); err != nil {
402 slog.Warn("Failed to check server version, restarting", "error", err)
403 needsStart = true
404 }
405 }
407 if needsStart {
408 if err := startDetachedServer(cmd); err != nil {
409 return err
410 }
411 }
413 var err error
414 for range 10 {
415 _, err = os.Stat(hostURL.Host)
416 if err == nil {
417 break
418 }
419 select {
420 case <-cmd.Context().Done():
421 return cmd.Context().Err()
422 case <-time.After(100 * time.Millisecond):
423 }
424 }
425 if err != nil {
426 return fmt.Errorf("failed to initialize crush server: %v", err)
427 }
428 }
430 return nil
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)
438 if err != nil {
439 return err
440 }
441 vi, err := c.VersionInfo(cmd.Context())
442 if err != nil {
443 return err
444 }
445 if vi.Version == version.Version {
446 return nil
447 }
448 slog.Info("Server version mismatch, restarting",
449 "server", vi.Version,
450 "client", version.Version,
451 )
452 _ = c.ShutdownServer(cmd.Context())
453 // Give the old process a moment to release the socket.
454 for range 20 {
455 if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
456 break
457 }
458 select {
459 case <-cmd.Context().Done():
460 return cmd.Context().Err()
461 case <-time.After(100 * time.Millisecond):
462 }
463 }
464 // Force-remove if the socket is still lingering.
465 _ = os.Remove(hostURL.Host)
466 return nil
469var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
471func startDetachedServer(cmd *cobra.Command) error {
472 exe, err := os.Executable()
473 if err != nil {
474 return fmt.Errorf("failed to get executable path: %v", err)
475 }
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)
481 }
483 cmdArgs := []string{"server"}
484 if clientHost != server.DefaultHost() {
485 cmdArgs = append(cmdArgs, "--host", clientHost)
486 }
488 c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
489 stdoutPath := filepath.Join(chDir, "stdout.log")
490 stderrPath := filepath.Join(chDir, "stderr.log")
491 detachProcess(c)
493 stdout, err := os.Create(stdoutPath)
494 if err != nil {
495 return fmt.Errorf("failed to create stdout log file: %v", err)
496 }
497 defer stdout.Close()
498 c.Stdout = stdout
500 stderr, err := os.Create(stderrPath)
501 if err != nil {
502 return fmt.Errorf("failed to create stderr log file: %v", err)
503 }
504 defer stderr.Close()
505 c.Stderr = stderr
507 if err := c.Start(); err != nil {
508 return fmt.Errorf("failed to start crush server: %v", err)
509 }
511 if err := c.Process.Release(); err != nil {
512 return fmt.Errorf("failed to detach crush server process: %v", err)
513 }
515 return nil
518func shouldEnableMetrics(cfg *config.Config) bool {
519 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
520 return false
521 }
522 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
523 return false
524 }
525 if cfg.Options.DisableMetrics {
526 return false
527 }
528 return true
531func MaybePrependStdin(prompt string) (string, error) {
532 if term.IsTerminal(os.Stdin.Fd()) {
533 return prompt, nil
534 }
535 fi, err := os.Stdin.Stat()
536 if err != nil {
537 return prompt, err
538 }
539 // Check if stdin is a named pipe ( | ) or regular file ( < ).
540 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
541 return prompt, nil
542 }
543 bts, err := io.ReadAll(os.Stdin)
544 if err != nil {
545 return prompt, err
546 }
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
553// support.
554func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
555 if sess, err := ws.GetSession(ctx, id); err == nil {
556 return sess, nil
557 }
559 sessions, err := ws.ListSessions(ctx)
560 if err != nil {
561 return session.Session{}, err
562 }
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)
569 }
570 }
572 switch len(matches) {
573 case 0:
574 return session.Session{}, fmt.Errorf("session not found: %s", id)
575 case 1:
576 return matches[0], nil
577 default:
578 return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
579 }
582func ResolveCwd(cmd *cobra.Command) (string, error) {
583 cwd, _ := cmd.Flags().GetString("cwd")
584 if cwd != "" {
585 err := os.Chdir(cwd)
586 if err != nil {
587 return "", fmt.Errorf("failed to change directory: %v", err)
588 }
589 return cwd, nil
590 }
591 cwd, err := os.Getwd()
592 if err != nil {
593 return "", fmt.Errorf("failed to get current working directory: %v", err)
594 }
595 return cwd, nil
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)
601 }
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)
610 }
611 }
613 return nil
616//go:embed gitignore/old
617var oldGitIgnore string
619//go:embed gitignore/default
620var defaultGitIgnore string