root.go

  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}