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