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