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 "charm.land/lipgloss/v2"
22 "github.com/charmbracelet/colorprofile"
23 "github.com/charmbracelet/crush/internal/app"
24 "github.com/charmbracelet/crush/internal/client"
25 "github.com/charmbracelet/crush/internal/config"
26 "github.com/charmbracelet/crush/internal/db"
27 "github.com/charmbracelet/crush/internal/event"
28 "github.com/charmbracelet/crush/internal/log"
29 "github.com/charmbracelet/crush/internal/projects"
30 "github.com/charmbracelet/crush/internal/proto"
31 "github.com/charmbracelet/crush/internal/server"
32 "github.com/charmbracelet/crush/internal/ui/common"
33 ui "github.com/charmbracelet/crush/internal/ui/model"
34 "github.com/charmbracelet/crush/internal/version"
35 "github.com/charmbracelet/fang"
36 uv "github.com/charmbracelet/ultraviolet"
37 "github.com/charmbracelet/x/ansi"
38 "github.com/charmbracelet/x/exp/charmtone"
39 "github.com/charmbracelet/x/term"
40 "github.com/spf13/cobra"
41)
42
43var clientHost string
44
45func init() {
46 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
47 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
48 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
49 rootCmd.Flags().BoolP("help", "h", false, "Help")
50 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
51
52 rootCmd.Flags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
53
54 rootCmd.AddCommand(
55 runCmd,
56 dirsCmd,
57 projectsCmd,
58 updateProvidersCmd,
59 logsCmd,
60 schemaCmd,
61 loginCmd,
62 statsCmd,
63 )
64}
65
66var rootCmd = &cobra.Command{
67 Use: "crush",
68 Short: "A terminal-first AI assistant for software development",
69 Long: "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
70 Example: `
71# Run in interactive mode
72crush
73
74# Run non-interactively
75crush run "Guess my 5 favorite PokΓ©mon"
76
77# Run a non-interactively with pipes and redirection
78cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
79
80# Run with debug logging in a specific directory
81crush --debug --cwd /path/to/project
82
83# Run in yolo mode (auto-accept all permissions; use with care)
84crush --yolo
85
86# Run with custom data directory
87crush --data-dir /path/to/custom/.crush
88 `,
89 RunE: func(cmd *cobra.Command, args []string) error {
90 hostURL, err := server.ParseHostURL(clientHost)
91 if err != nil {
92 return fmt.Errorf("invalid host URL: %v", err)
93 }
94
95 if err := ensureServer(cmd, hostURL); err != nil {
96 return err
97 }
98
99 appInstance, err := setupAppWithProgressBar(cmd)
100 if err != nil {
101 return err
102 }
103 defer appInstance.Shutdown()
104
105 // Register the workspace with the server so it tracks active
106 // clients and auto-shuts down when the last one exits.
107 cwd, _ := ResolveCwd(cmd)
108 dataDir, _ := cmd.Flags().GetString("data-dir")
109 debug, _ := cmd.Flags().GetBool("debug")
110 yolo, _ := cmd.Flags().GetBool("yolo")
111
112 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
113 if err != nil {
114 return fmt.Errorf("failed to create client: %v", err)
115 }
116
117 ws, err := c.CreateWorkspace(cmd.Context(), proto.Workspace{
118 Path: cwd,
119 DataDir: dataDir,
120 Debug: debug,
121 YOLO: yolo,
122 Version: version.Version,
123 })
124 if err != nil {
125 return fmt.Errorf("failed to register workspace: %v", err)
126 }
127 defer func() { _ = c.DeleteWorkspace(cmd.Context(), ws.ID) }()
128
129 event.AppInitialized()
130
131 // Set up the TUI.
132 var env uv.Environ = os.Environ()
133
134 com := common.DefaultCommon(appInstance)
135 model := ui.New(com)
136
137 program := tea.NewProgram(
138 model,
139 tea.WithEnvironment(env),
140 tea.WithContext(cmd.Context()),
141 tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
142 )
143 go appInstance.Subscribe(program)
144
145 if _, err := program.Run(); err != nil {
146 event.Error(err)
147 slog.Error("TUI run error", "error", err)
148 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
149 }
150 return nil
151 },
152}
153
154var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
155 ββββββββ ββββββββ
156 βββββββββββ βββββββββββ
157ββββββββββββββββββββββββββββ
158ββββββββββββββββββββββββββββ
159ββββββββββββββββββββββββββββ
160ββββββββββ ββββββ ββββββββββ
161ββββββββββββββββββββββββββββ
162 ββββββββββββββββββββββββ
163 ββββββββββββββββββββ
164 ββββββββββββββ
165 ββββββ
166`)
167
168// copied from cobra:
169const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
170`
171
172func Execute() {
173 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
174 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
175 // finally prepend it in the version template.
176 // Unfortunately cobra doesn't give us a way to set a function to handle
177 // printing the version, and PreRunE runs after the version is already
178 // handled, so that doesn't work either.
179 // This is the only way I could find that works relatively well.
180 if term.IsTerminal(os.Stdout.Fd()) {
181 var b bytes.Buffer
182 w := colorprofile.NewWriter(os.Stdout, os.Environ())
183 w.Forward = &b
184 _, _ = w.WriteString(heartbit.String())
185 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
186 }
187 if err := fang.Execute(
188 context.Background(),
189 rootCmd,
190 fang.WithVersion(version.Version),
191 fang.WithNotifySignal(os.Interrupt),
192 ); err != nil {
193 os.Exit(1)
194 }
195}
196
197// supportsProgressBar tries to determine whether the current terminal supports
198// progress bars by looking into environment variables.
199func supportsProgressBar() bool {
200 if !term.IsTerminal(os.Stderr.Fd()) {
201 return false
202 }
203 termProg := os.Getenv("TERM_PROGRAM")
204 _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
205
206 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
207}
208
209func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
210 app, err := setupApp(cmd)
211 if err != nil {
212 return nil, err
213 }
214
215 // Check if progress bar is enabled in config (defaults to true if nil)
216 progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
217 if progressEnabled && supportsProgressBar() {
218 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
219 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
220 }
221
222 return app, nil
223}
224
225// setupApp handles the common setup logic for both interactive and non-interactive modes.
226// It returns the app instance, config, cleanup function, and any error.
227func setupApp(cmd *cobra.Command) (*app.App, error) {
228 debug, _ := cmd.Flags().GetBool("debug")
229 yolo, _ := cmd.Flags().GetBool("yolo")
230 dataDir, _ := cmd.Flags().GetString("data-dir")
231 ctx := cmd.Context()
232
233 cwd, err := ResolveCwd(cmd)
234 if err != nil {
235 return nil, err
236 }
237
238 store, err := config.Init(cwd, dataDir, debug)
239 if err != nil {
240 return nil, err
241 }
242
243 store.Overrides().SkipPermissionRequests = yolo
244 cfg := store.Config()
245
246 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
247 return nil, err
248 }
249
250 // Register this project in the centralized projects list.
251 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
252 slog.Warn("Failed to register project", "error", err)
253 // Non-fatal: continue even if registration fails
254 }
255
256 // Connect to DB; this will also run migrations.
257 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
258 if err != nil {
259 return nil, err
260 }
261
262 appInstance, err := app.New(ctx, conn, store)
263 if err != nil {
264 slog.Error("Failed to create app instance", "error", err)
265 return nil, err
266 }
267
268 if shouldEnableMetrics(cfg) {
269 event.Init()
270 }
271
272 return appInstance, nil
273}
274
275// setupClientApp sets up a client-based workspace via the server. It
276// auto-starts a detached server process if the socket does not exist.
277func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *proto.Workspace, error) {
278 debug, _ := cmd.Flags().GetBool("debug")
279 yolo, _ := cmd.Flags().GetBool("yolo")
280 dataDir, _ := cmd.Flags().GetString("data-dir")
281 ctx := cmd.Context()
282
283 cwd, err := ResolveCwd(cmd)
284 if err != nil {
285 return nil, nil, err
286 }
287
288 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
289 if err != nil {
290 return nil, nil, err
291 }
292
293 ws, err := c.CreateWorkspace(ctx, proto.Workspace{
294 Path: cwd,
295 DataDir: dataDir,
296 Debug: debug,
297 YOLO: yolo,
298 Env: os.Environ(),
299 })
300 if err != nil {
301 return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
302 }
303
304 cfg, err := c.GetGlobalConfig(cmd.Context())
305 if err != nil {
306 return nil, nil, fmt.Errorf("failed to get global config: %v", err)
307 }
308
309 if shouldEnableMetrics(cfg) {
310 event.Init()
311 }
312
313 return c, ws, nil
314}
315
316// ensureServer auto-starts a detached server if the socket file does not
317// exist. When connecting to an existing server, it waits for the health
318// endpoint to respond.
319func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
320 switch hostURL.Scheme {
321 case "unix", "npipe":
322 _, err := os.Stat(hostURL.Host)
323 if err != nil && errors.Is(err, fs.ErrNotExist) {
324 if err := startDetachedServer(cmd); err != nil {
325 return err
326 }
327 }
328
329 for range 10 {
330 _, err = os.Stat(hostURL.Host)
331 if err == nil {
332 break
333 }
334 select {
335 case <-cmd.Context().Done():
336 return cmd.Context().Err()
337 case <-time.After(100 * time.Millisecond):
338 }
339 }
340 if err != nil {
341 return fmt.Errorf("failed to initialize crush server: %v", err)
342 }
343 }
344
345 return nil
346}
347
348// waitForHealth polls the server's health endpoint until it responds.
349func waitForHealth(ctx context.Context, c *client.Client) error {
350 var err error
351 for range 10 {
352 err = c.Health(ctx)
353 if err == nil {
354 return nil
355 }
356 select {
357 case <-ctx.Done():
358 return ctx.Err()
359 case <-time.After(100 * time.Millisecond):
360 }
361 }
362 return fmt.Errorf("failed to connect to crush server: %v", err)
363}
364
365// streamEvents forwards SSE events from the client to the TUI program.
366func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
367 defer log.RecoverPanic("app.Subscribe", func() {
368 slog.Info("TUI subscription panic: attempting graceful shutdown")
369 p.Quit()
370 })
371
372 for {
373 select {
374 case <-ctx.Done():
375 slog.Debug("TUI message handler shutting down")
376 return
377 case ev, ok := <-evc:
378 if !ok {
379 slog.Debug("TUI message channel closed")
380 return
381 }
382 p.Send(ev)
383 }
384 }
385}
386
387var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
388
389func startDetachedServer(cmd *cobra.Command) error {
390 exe, err := os.Executable()
391 if err != nil {
392 return fmt.Errorf("failed to get executable path: %v", err)
393 }
394
395 safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
396 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
397 if err := os.MkdirAll(chDir, 0o700); err != nil {
398 return fmt.Errorf("failed to create server working directory: %v", err)
399 }
400
401 cmdArgs := []string{"server"}
402 if clientHost != server.DefaultHost() {
403 cmdArgs = append(cmdArgs, "--host", clientHost)
404 }
405
406 c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
407 stdoutPath := filepath.Join(chDir, "stdout.log")
408 stderrPath := filepath.Join(chDir, "stderr.log")
409 detachProcess(c)
410
411 stdout, err := os.Create(stdoutPath)
412 if err != nil {
413 return fmt.Errorf("failed to create stdout log file: %v", err)
414 }
415 defer stdout.Close()
416 c.Stdout = stdout
417
418 stderr, err := os.Create(stderrPath)
419 if err != nil {
420 return fmt.Errorf("failed to create stderr log file: %v", err)
421 }
422 defer stderr.Close()
423 c.Stderr = stderr
424
425 if err := c.Start(); err != nil {
426 return fmt.Errorf("failed to start crush server: %v", err)
427 }
428
429 if err := c.Process.Release(); err != nil {
430 return fmt.Errorf("failed to detach crush server process: %v", err)
431 }
432
433 return nil
434}
435
436func shouldEnableMetrics(cfg *config.Config) bool {
437 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
438 return false
439 }
440 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
441 return false
442 }
443 if cfg.Options.DisableMetrics {
444 return false
445 }
446 return true
447}
448
449func MaybePrependStdin(prompt string) (string, error) {
450 if term.IsTerminal(os.Stdin.Fd()) {
451 return prompt, nil
452 }
453 fi, err := os.Stdin.Stat()
454 if err != nil {
455 return prompt, err
456 }
457 // Check if stdin is a named pipe ( | ) or regular file ( < ).
458 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
459 return prompt, nil
460 }
461 bts, err := io.ReadAll(os.Stdin)
462 if err != nil {
463 return prompt, err
464 }
465 return string(bts) + "\n\n" + prompt, nil
466}
467
468func ResolveCwd(cmd *cobra.Command) (string, error) {
469 cwd, _ := cmd.Flags().GetString("cwd")
470 if cwd != "" {
471 err := os.Chdir(cwd)
472 if err != nil {
473 return "", fmt.Errorf("failed to change directory: %v", err)
474 }
475 return cwd, nil
476 }
477 cwd, err := os.Getwd()
478 if err != nil {
479 return "", fmt.Errorf("failed to get current working directory: %v", err)
480 }
481 return cwd, nil
482}
483
484func createDotCrushDir(dir string) error {
485 if err := os.MkdirAll(dir, 0o700); err != nil {
486 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
487 }
488
489 gitIgnorePath := filepath.Join(dir, ".gitignore")
490 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
491 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
492 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
493 }
494 }
495
496 return nil
497}