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