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 schemaCmd,
67 loginCmd,
68 statsCmd,
69 sessionCmd,
70 )
71}
72
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
80
81# Run non-interactively
82crush run "Guess my 5 favorite PokΓ©mon"
83
84# Run a non-interactively with pipes and redirection
85cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
86
87# Run with debug logging in a specific directory
88crush --debug --cwd /path/to/project
89
90# Run in yolo mode (auto-accept all permissions; use with care)
91crush --yolo
92
93# Run with custom data directory
94crush --data-dir /path/to/custom/.crush
95
96# Continue a previous session
97crush --session {session-id}
98
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")
105
106 ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
107 if err != nil {
108 return err
109 }
110 defer cleanup()
111
112 if sessionID != "" {
113 sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
114 if err != nil {
115 return err
116 }
117 sessionID = sess.ID
118 }
119
120 event.AppInitialized()
121
122 com := common.DefaultCommon(ws)
123 model := ui.New(com, sessionID, continueLast)
124
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)
133
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 },
141}
142
143var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
144 ββββββββ ββββββββ
145 βββββββββββ βββββββββββ
146ββββββββββββββββββββββββββββ
147ββββββββββββββββββββββββββββ
148ββββββββββββββββββββββββββββ
149ββββββββββ ββββββ ββββββββββ
150ββββββββββββββββββββββββββββ
151 ββββββββββββββββββββββββ
152 ββββββββββββββββββββ
153 ββββββββββββββ
154 ββββββ
155`)
156
157// copied from cobra:
158const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
159`
160
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))
170
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 }
193}
194
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")
203
204 return isWindowsTerminal || xstrings.ContainsAnyOf(strings.ToLower(termProg), "ghostty", "iterm2", "rio")
205}
206
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
212}
213
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 }
221
222 ws, cleanup, err := setupWorkspace(cmd)
223
224 if showProgress {
225 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
226 }
227
228 return ws, cleanup, err
229}
230
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)
240}
241
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()
249
250 cwd, err := ResolveCwd(cmd)
251 if err != nil {
252 return nil, nil, err
253 }
254
255 store, err := config.Init(cwd, dataDir, debug)
256 if err != nil {
257 return nil, nil, err
258 }
259
260 cfg := store.Config()
261 store.Overrides().SkipPermissionRequests = yolo
262
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 }
266
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 }
273
274 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
275 slog.Warn("Failed to register project", "error", err)
276 }
277
278 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
279 if err != nil {
280 return nil, nil, err
281 }
282
283 logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
284 crushlog.Setup(logFile, debug)
285
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 }
292
293 if shouldEnableMetrics(cfg) {
294 event.Init()
295 }
296
297 ws := workspace.NewAppWorkspace(appInstance, store)
298 cleanup := func() { appInstance.Shutdown() }
299 return ws, cleanup, nil
300}
301
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 }
309
310 clientWs := workspace.NewClientWorkspace(c, *protoWs)
311
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 }
317
318 return clientWs, cleanupServer, nil
319}
320
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 }
328
329 if err := ensureServer(cmd, hostURL); err != nil {
330 return nil, nil, nil, err
331 }
332
333 debug, _ := cmd.Flags().GetBool("debug")
334 yolo, _ := cmd.Flags().GetBool("yolo")
335 dataDir, _ := cmd.Flags().GetString("data-dir")
336 ctx := cmd.Context()
337
338 cwd, err := ResolveCwd(cmd)
339 if err != nil {
340 return nil, nil, nil, err
341 }
342
343 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
344 if err != nil {
345 return nil, nil, nil, err
346 }
347
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 }
356
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 }
376
377 if shouldEnableMetrics(ws.Config) {
378 event.Init()
379 }
380
381 if ws.Config != nil {
382 logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
383 crushlog.Setup(logFile, debug)
384 }
385
386 cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
387 return c, ws, cleanup, nil
388}
389
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 }
406
407 if needsStart {
408 if err := startDetachedServer(cmd); err != nil {
409 return err
410 }
411 }
412
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 }
429
430 return nil
431}
432
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
467}
468
469var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
470
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 }
476
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 }
482
483 cmdArgs := []string{"server"}
484 if clientHost != server.DefaultHost() {
485 cmdArgs = append(cmdArgs, "--host", clientHost)
486 }
487
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)
492
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
499
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
506
507 if err := c.Start(); err != nil {
508 return fmt.Errorf("failed to start crush server: %v", err)
509 }
510
511 if err := c.Process.Release(); err != nil {
512 return fmt.Errorf("failed to detach crush server process: %v", err)
513 }
514
515 return nil
516}
517
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
529}
530
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
548}
549
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 }
558
559 sessions, err := ws.ListSessions(ctx)
560 if err != nil {
561 return session.Session{}, err
562 }
563
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 }
571
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 }
580}
581
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
596}
597
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 }
602
603 gitIgnorePath := filepath.Join(dir, ".gitignore")
604 content, err := os.ReadFile(gitIgnorePath)
605
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 }
612
613 return nil
614}
615
616//go:embed gitignore/old
617var oldGitIgnore string
618
619//go:embed gitignore/default
620var defaultGitIgnore string