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 return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
285 }
286
287 if shouldEnableMetrics(ws.Config) {
288 event.Init()
289 }
290
291 return c, ws, nil
292}
293
294// ensureServer auto-starts a detached server if the socket file does not
295// exist. When the socket exists, it verifies that the running server
296// version matches the client; on mismatch it shuts down the old server
297// and starts a fresh one.
298func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
299 switch hostURL.Scheme {
300 case "unix", "npipe":
301 needsStart := false
302 if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
303 needsStart = true
304 } else if err == nil {
305 if err := restartIfStale(cmd, hostURL); err != nil {
306 slog.Warn("Failed to check server version, restarting", "error", err)
307 needsStart = true
308 }
309 }
310
311 if needsStart {
312 if err := startDetachedServer(cmd); err != nil {
313 return err
314 }
315 }
316
317 var err error
318 for range 10 {
319 _, err = os.Stat(hostURL.Host)
320 if err == nil {
321 break
322 }
323 select {
324 case <-cmd.Context().Done():
325 return cmd.Context().Err()
326 case <-time.After(100 * time.Millisecond):
327 }
328 }
329 if err != nil {
330 return fmt.Errorf("failed to initialize crush server: %v", err)
331 }
332 }
333
334 return nil
335}
336
337// restartIfStale checks whether the running server matches the current
338// client version. When they differ, it sends a shutdown command and
339// removes the stale socket so the caller can start a fresh server.
340func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
341 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
342 if err != nil {
343 return err
344 }
345 vi, err := c.VersionInfo(cmd.Context())
346 if err != nil {
347 return err
348 }
349 if vi.Version == version.Version {
350 return nil
351 }
352 slog.Info("Server version mismatch, restarting",
353 "server", vi.Version,
354 "client", version.Version,
355 )
356 _ = c.ShutdownServer(cmd.Context())
357 // Give the old process a moment to release the socket.
358 for range 20 {
359 if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
360 break
361 }
362 select {
363 case <-cmd.Context().Done():
364 return cmd.Context().Err()
365 case <-time.After(100 * time.Millisecond):
366 }
367 }
368 // Force-remove if the socket is still lingering.
369 _ = os.Remove(hostURL.Host)
370 return nil
371}
372
373var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
374
375func startDetachedServer(cmd *cobra.Command) error {
376 exe, err := os.Executable()
377 if err != nil {
378 return fmt.Errorf("failed to get executable path: %v", err)
379 }
380
381 safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
382 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
383 if err := os.MkdirAll(chDir, 0o700); err != nil {
384 return fmt.Errorf("failed to create server working directory: %v", err)
385 }
386
387 cmdArgs := []string{"server"}
388 if clientHost != server.DefaultHost() {
389 cmdArgs = append(cmdArgs, "--host", clientHost)
390 }
391
392 c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
393 stdoutPath := filepath.Join(chDir, "stdout.log")
394 stderrPath := filepath.Join(chDir, "stderr.log")
395 detachProcess(c)
396
397 stdout, err := os.Create(stdoutPath)
398 if err != nil {
399 return fmt.Errorf("failed to create stdout log file: %v", err)
400 }
401 defer stdout.Close()
402 c.Stdout = stdout
403
404 stderr, err := os.Create(stderrPath)
405 if err != nil {
406 return fmt.Errorf("failed to create stderr log file: %v", err)
407 }
408 defer stderr.Close()
409 c.Stderr = stderr
410
411 if err := c.Start(); err != nil {
412 return fmt.Errorf("failed to start crush server: %v", err)
413 }
414
415 if err := c.Process.Release(); err != nil {
416 return fmt.Errorf("failed to detach crush server process: %v", err)
417 }
418
419 return nil
420}
421
422func shouldEnableMetrics(cfg *config.Config) bool {
423 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
424 return false
425 }
426 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
427 return false
428 }
429 if cfg.Options.DisableMetrics {
430 return false
431 }
432 return true
433}
434
435func MaybePrependStdin(prompt string) (string, error) {
436 if term.IsTerminal(os.Stdin.Fd()) {
437 return prompt, nil
438 }
439 fi, err := os.Stdin.Stat()
440 if err != nil {
441 return prompt, err
442 }
443 // Check if stdin is a named pipe ( | ) or regular file ( < ).
444 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
445 return prompt, nil
446 }
447 bts, err := io.ReadAll(os.Stdin)
448 if err != nil {
449 return prompt, err
450 }
451 return string(bts) + "\n\n" + prompt, nil
452}
453
454func ResolveCwd(cmd *cobra.Command) (string, error) {
455 cwd, _ := cmd.Flags().GetString("cwd")
456 if cwd != "" {
457 err := os.Chdir(cwd)
458 if err != nil {
459 return "", fmt.Errorf("failed to change directory: %v", err)
460 }
461 return cwd, nil
462 }
463 cwd, err := os.Getwd()
464 if err != nil {
465 return "", fmt.Errorf("failed to get current working directory: %v", err)
466 }
467 return cwd, nil
468}
469
470func createDotCrushDir(dir string) error {
471 if err := os.MkdirAll(dir, 0o700); err != nil {
472 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
473 }
474
475 gitIgnorePath := filepath.Join(dir, ".gitignore")
476 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
477 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
478 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
479 }
480 }
481
482 return nil
483}