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