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