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