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