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 cfg := store.Config()
244 if cfg.Permissions == nil {
245 cfg.Permissions = &config.Permissions{}
246 }
247 cfg.Permissions.SkipRequests = yolo
248
249 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
250 return nil, err
251 }
252
253 // Register this project in the centralized projects list.
254 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
255 slog.Warn("Failed to register project", "error", err)
256 // Non-fatal: continue even if registration fails
257 }
258
259 // Connect to DB; this will also run migrations.
260 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
261 if err != nil {
262 return nil, err
263 }
264
265 appInstance, err := app.New(ctx, conn, store)
266 if err != nil {
267 slog.Error("Failed to create app instance", "error", err)
268 return nil, err
269 }
270
271 if shouldEnableMetrics(cfg) {
272 event.Init()
273 }
274
275 return appInstance, nil
276}
277
278// setupClientApp sets up a client-based workspace via the server. It
279// auto-starts a detached server process if the socket does not exist.
280func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *proto.Workspace, error) {
281 debug, _ := cmd.Flags().GetBool("debug")
282 yolo, _ := cmd.Flags().GetBool("yolo")
283 dataDir, _ := cmd.Flags().GetString("data-dir")
284 ctx := cmd.Context()
285
286 cwd, err := ResolveCwd(cmd)
287 if err != nil {
288 return nil, nil, err
289 }
290
291 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
292 if err != nil {
293 return nil, nil, err
294 }
295
296 ws, err := c.CreateWorkspace(ctx, proto.Workspace{
297 Path: cwd,
298 DataDir: dataDir,
299 Debug: debug,
300 YOLO: yolo,
301 Env: os.Environ(),
302 })
303 if err != nil {
304 return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
305 }
306
307 cfg, err := c.GetGlobalConfig(cmd.Context())
308 if err != nil {
309 return nil, nil, fmt.Errorf("failed to get global config: %v", err)
310 }
311
312 if shouldEnableMetrics(cfg) {
313 event.Init()
314 }
315
316 return c, ws, nil
317}
318
319// ensureServer auto-starts a detached server if the socket file does not
320// exist. When connecting to an existing server, it waits for the health
321// endpoint to respond.
322func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
323 switch hostURL.Scheme {
324 case "unix", "npipe":
325 _, err := os.Stat(hostURL.Host)
326 if err != nil && errors.Is(err, fs.ErrNotExist) {
327 if err := startDetachedServer(cmd); err != nil {
328 return err
329 }
330 }
331
332 for range 10 {
333 _, err = os.Stat(hostURL.Host)
334 if err == nil {
335 break
336 }
337 select {
338 case <-cmd.Context().Done():
339 return cmd.Context().Err()
340 case <-time.After(100 * time.Millisecond):
341 }
342 }
343 if err != nil {
344 return fmt.Errorf("failed to initialize crush server: %v", err)
345 }
346 }
347
348 return nil
349}
350
351// waitForHealth polls the server's health endpoint until it responds.
352func waitForHealth(ctx context.Context, c *client.Client) error {
353 var err error
354 for range 10 {
355 err = c.Health(ctx)
356 if err == nil {
357 return nil
358 }
359 select {
360 case <-ctx.Done():
361 return ctx.Err()
362 case <-time.After(100 * time.Millisecond):
363 }
364 }
365 return fmt.Errorf("failed to connect to crush server: %v", err)
366}
367
368// streamEvents forwards SSE events from the client to the TUI program.
369func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
370 defer log.RecoverPanic("app.Subscribe", func() {
371 slog.Info("TUI subscription panic: attempting graceful shutdown")
372 p.Quit()
373 })
374
375 for {
376 select {
377 case <-ctx.Done():
378 slog.Debug("TUI message handler shutting down")
379 return
380 case ev, ok := <-evc:
381 if !ok {
382 slog.Debug("TUI message channel closed")
383 return
384 }
385 p.Send(ev)
386 }
387 }
388}
389
390var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
391
392func startDetachedServer(cmd *cobra.Command) error {
393 exe, err := os.Executable()
394 if err != nil {
395 return fmt.Errorf("failed to get executable path: %v", err)
396 }
397
398 safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
399 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
400 if err := os.MkdirAll(chDir, 0o700); err != nil {
401 return fmt.Errorf("failed to create server working directory: %v", err)
402 }
403
404 cmdArgs := []string{"server"}
405 if clientHost != server.DefaultHost() {
406 cmdArgs = append(cmdArgs, "--host", clientHost)
407 }
408
409 c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
410 stdoutPath := filepath.Join(chDir, "stdout.log")
411 stderrPath := filepath.Join(chDir, "stderr.log")
412 detachProcess(c)
413
414 stdout, err := os.Create(stdoutPath)
415 if err != nil {
416 return fmt.Errorf("failed to create stdout log file: %v", err)
417 }
418 defer stdout.Close()
419 c.Stdout = stdout
420
421 stderr, err := os.Create(stderrPath)
422 if err != nil {
423 return fmt.Errorf("failed to create stderr log file: %v", err)
424 }
425 defer stderr.Close()
426 c.Stderr = stderr
427
428 if err := c.Start(); err != nil {
429 return fmt.Errorf("failed to start crush server: %v", err)
430 }
431
432 if err := c.Process.Release(); err != nil {
433 return fmt.Errorf("failed to detach crush server process: %v", err)
434 }
435
436 return nil
437}
438
439func shouldEnableMetrics(cfg *config.Config) bool {
440 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
441 return false
442 }
443 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
444 return false
445 }
446 if cfg.Options.DisableMetrics {
447 return false
448 }
449 return true
450}
451
452func MaybePrependStdin(prompt string) (string, error) {
453 if term.IsTerminal(os.Stdin.Fd()) {
454 return prompt, nil
455 }
456 fi, err := os.Stdin.Stat()
457 if err != nil {
458 return prompt, err
459 }
460 // Check if stdin is a named pipe ( | ) or regular file ( < ).
461 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
462 return prompt, nil
463 }
464 bts, err := io.ReadAll(os.Stdin)
465 if err != nil {
466 return prompt, err
467 }
468 return string(bts) + "\n\n" + prompt, nil
469}
470
471func ResolveCwd(cmd *cobra.Command) (string, error) {
472 cwd, _ := cmd.Flags().GetString("cwd")
473 if cwd != "" {
474 err := os.Chdir(cwd)
475 if err != nil {
476 return "", fmt.Errorf("failed to change directory: %v", err)
477 }
478 return cwd, nil
479 }
480 cwd, err := os.Getwd()
481 if err != nil {
482 return "", fmt.Errorf("failed to get current working directory: %v", err)
483 }
484 return cwd, nil
485}
486
487func createDotCrushDir(dir string) error {
488 if err := os.MkdirAll(dir, 0o700); err != nil {
489 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
490 }
491
492 gitIgnorePath := filepath.Join(dir, ".gitignore")
493 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
494 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
495 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
496 }
497 }
498
499 return nil
500}