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		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}