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 app, err := setupAppWithProgressBar(cmd)
91 if err != nil {
92 return err
93 }
94 defer app.Shutdown()
95
96 event.AppInitialized()
97
98 // Set up the TUI.
99 var env uv.Environ = os.Environ()
100
101 com := common.DefaultCommon(app)
102 model := ui.New(com)
103
104 program := tea.NewProgram(
105 model,
106 tea.WithEnvironment(env),
107 tea.WithContext(cmd.Context()),
108 tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
109 )
110 go app.Subscribe(program)
111
112 if _, err := program.Run(); err != nil {
113 event.Error(err)
114 slog.Error("TUI run error", "error", err)
115 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
116 }
117 return nil
118 },
119}
120
121var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
122 ββββββββ ββββββββ
123 βββββββββββ βββββββββββ
124ββββββββββββββββββββββββββββ
125ββββββββββββββββββββββββββββ
126ββββββββββββββββββββββββββββ
127ββββββββββ ββββββ ββββββββββ
128ββββββββββββββββββββββββββββ
129 ββββββββββββββββββββββββ
130 ββββββββββββββββββββ
131 ββββββββββββββ
132 ββββββ
133`)
134
135// copied from cobra:
136const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
137`
138
139func Execute() {
140 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
141 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
142 // finally prepend it in the version template.
143 // Unfortunately cobra doesn't give us a way to set a function to handle
144 // printing the version, and PreRunE runs after the version is already
145 // handled, so that doesn't work either.
146 // This is the only way I could find that works relatively well.
147 if term.IsTerminal(os.Stdout.Fd()) {
148 var b bytes.Buffer
149 w := colorprofile.NewWriter(os.Stdout, os.Environ())
150 w.Forward = &b
151 _, _ = w.WriteString(heartbit.String())
152 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
153 }
154 if err := fang.Execute(
155 context.Background(),
156 rootCmd,
157 fang.WithVersion(version.Version),
158 fang.WithNotifySignal(os.Interrupt),
159 ); err != nil {
160 os.Exit(1)
161 }
162}
163
164// supportsProgressBar tries to determine whether the current terminal supports
165// progress bars by looking into environment variables.
166func supportsProgressBar() bool {
167 if !term.IsTerminal(os.Stderr.Fd()) {
168 return false
169 }
170 termProg := os.Getenv("TERM_PROGRAM")
171 _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
172
173 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
174}
175
176func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
177 app, err := setupApp(cmd)
178 if err != nil {
179 return nil, err
180 }
181
182 // Check if progress bar is enabled in config (defaults to true if nil)
183 progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
184 if progressEnabled && supportsProgressBar() {
185 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
186 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
187 }
188
189 return app, nil
190}
191
192// setupApp handles the common setup logic for both interactive and non-interactive modes.
193// It returns the app instance, config, cleanup function, and any error.
194func setupApp(cmd *cobra.Command) (*app.App, error) {
195 debug, _ := cmd.Flags().GetBool("debug")
196 yolo, _ := cmd.Flags().GetBool("yolo")
197 dataDir, _ := cmd.Flags().GetString("data-dir")
198 ctx := cmd.Context()
199
200 cwd, err := ResolveCwd(cmd)
201 if err != nil {
202 return nil, err
203 }
204
205 cfg, err := config.Init(cwd, dataDir, debug)
206 if err != nil {
207 return nil, err
208 }
209
210 if cfg.Permissions == nil {
211 cfg.Permissions = &config.Permissions{}
212 }
213 cfg.Permissions.SkipRequests = yolo
214
215 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
216 return nil, err
217 }
218
219 // Register this project in the centralized projects list.
220 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
221 slog.Warn("Failed to register project", "error", err)
222 // Non-fatal: continue even if registration fails
223 }
224
225 // Connect to DB; this will also run migrations.
226 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
227 if err != nil {
228 return nil, err
229 }
230
231 appInstance, err := app.New(ctx, conn, cfg)
232 if err != nil {
233 slog.Error("Failed to create app instance", "error", err)
234 return nil, err
235 }
236
237 if shouldEnableMetrics(cfg) {
238 event.Init()
239 }
240
241 return appInstance, nil
242}
243
244// setupClientApp sets up a client-based workspace via the server. It
245// auto-starts a detached server process if the socket does not exist.
246func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *proto.Workspace, error) {
247 debug, _ := cmd.Flags().GetBool("debug")
248 yolo, _ := cmd.Flags().GetBool("yolo")
249 dataDir, _ := cmd.Flags().GetString("data-dir")
250 ctx := cmd.Context()
251
252 cwd, err := ResolveCwd(cmd)
253 if err != nil {
254 return nil, nil, err
255 }
256
257 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
258 if err != nil {
259 return nil, nil, err
260 }
261
262 ws, err := c.CreateWorkspace(ctx, proto.Workspace{
263 Path: cwd,
264 DataDir: dataDir,
265 Debug: debug,
266 YOLO: yolo,
267 Env: os.Environ(),
268 })
269 if err != nil {
270 return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
271 }
272
273 cfg, err := c.GetGlobalConfig(cmd.Context())
274 if err != nil {
275 return nil, nil, fmt.Errorf("failed to get global config: %v", err)
276 }
277
278 if shouldEnableMetrics(cfg) {
279 event.Init()
280 }
281
282 return c, ws, nil
283}
284
285// ensureServer auto-starts a detached server if the socket file does not
286// exist.
287func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
288 switch hostURL.Scheme {
289 case "unix", "npipe":
290 _, err := os.Stat(hostURL.Host)
291 if err != nil && errors.Is(err, fs.ErrNotExist) {
292 if err := startDetachedServer(cmd); err != nil {
293 return err
294 }
295 }
296
297 for range 10 {
298 _, err = os.Stat(hostURL.Host)
299 if err == nil {
300 break
301 }
302 select {
303 case <-cmd.Context().Done():
304 return cmd.Context().Err()
305 case <-time.After(100 * time.Millisecond):
306 }
307 }
308 if err != nil {
309 return fmt.Errorf("failed to initialize crush server: %v", err)
310 }
311 default:
312 // TCP: assume server is already running.
313 }
314 return nil
315}
316
317// waitForHealth polls the server's health endpoint until it responds.
318func waitForHealth(ctx context.Context, c *client.Client) error {
319 var err error
320 for range 10 {
321 err = c.Health(ctx)
322 if err == nil {
323 return nil
324 }
325 select {
326 case <-ctx.Done():
327 return ctx.Err()
328 case <-time.After(100 * time.Millisecond):
329 }
330 }
331 return fmt.Errorf("failed to connect to crush server: %v", err)
332}
333
334// streamEvents forwards SSE events from the client to the TUI program.
335func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
336 defer log.RecoverPanic("app.Subscribe", func() {
337 slog.Info("TUI subscription panic: attempting graceful shutdown")
338 p.Quit()
339 })
340
341 for {
342 select {
343 case <-ctx.Done():
344 slog.Debug("TUI message handler shutting down")
345 return
346 case ev, ok := <-evc:
347 if !ok {
348 slog.Debug("TUI message channel closed")
349 return
350 }
351 p.Send(ev)
352 }
353 }
354}
355
356var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
357
358func startDetachedServer(cmd *cobra.Command) error {
359 exe, err := os.Executable()
360 if err != nil {
361 return fmt.Errorf("failed to get executable path: %v", err)
362 }
363
364 safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
365 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
366 if err := os.MkdirAll(chDir, 0o700); err != nil {
367 return fmt.Errorf("failed to create server working directory: %v", err)
368 }
369
370 cmdArgs := []string{"server"}
371 if clientHost != server.DefaultHost() {
372 cmdArgs = append(cmdArgs, "--host", clientHost)
373 }
374
375 c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
376 stdoutPath := filepath.Join(chDir, "stdout.log")
377 stderrPath := filepath.Join(chDir, "stderr.log")
378 detachProcess(c)
379
380 stdout, err := os.Create(stdoutPath)
381 if err != nil {
382 return fmt.Errorf("failed to create stdout log file: %v", err)
383 }
384 defer stdout.Close()
385 c.Stdout = stdout
386
387 stderr, err := os.Create(stderrPath)
388 if err != nil {
389 return fmt.Errorf("failed to create stderr log file: %v", err)
390 }
391 defer stderr.Close()
392 c.Stderr = stderr
393
394 if err := c.Start(); err != nil {
395 return fmt.Errorf("failed to start crush server: %v", err)
396 }
397
398 if err := c.Process.Release(); err != nil {
399 return fmt.Errorf("failed to detach crush server process: %v", err)
400 }
401
402 return nil
403}
404
405func shouldEnableMetrics(cfg *config.Config) bool {
406 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
407 return false
408 }
409 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
410 return false
411 }
412 if cfg.Options.DisableMetrics {
413 return false
414 }
415 return true
416}
417
418func MaybePrependStdin(prompt string) (string, error) {
419 if term.IsTerminal(os.Stdin.Fd()) {
420 return prompt, nil
421 }
422 fi, err := os.Stdin.Stat()
423 if err != nil {
424 return prompt, err
425 }
426 // Check if stdin is a named pipe ( | ) or regular file ( < ).
427 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
428 return prompt, nil
429 }
430 bts, err := io.ReadAll(os.Stdin)
431 if err != nil {
432 return prompt, err
433 }
434 return string(bts) + "\n\n" + prompt, nil
435}
436
437func ResolveCwd(cmd *cobra.Command) (string, error) {
438 cwd, _ := cmd.Flags().GetString("cwd")
439 if cwd != "" {
440 err := os.Chdir(cwd)
441 if err != nil {
442 return "", fmt.Errorf("failed to change directory: %v", err)
443 }
444 return cwd, nil
445 }
446 cwd, err := os.Getwd()
447 if err != nil {
448 return "", fmt.Errorf("failed to get current working directory: %v", err)
449 }
450 return cwd, nil
451}
452
453func createDotCrushDir(dir string) error {
454 if err := os.MkdirAll(dir, 0o700); err != nil {
455 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
456 }
457
458 gitIgnorePath := filepath.Join(dir, ".gitignore")
459 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
460 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
461 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
462 }
463 }
464
465 return nil
466}