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	cfg, err := config.Init(cwd, dataDir, debug)
239	if err != nil {
240		return nil, err
241	}
242
243	if cfg.Permissions == nil {
244		cfg.Permissions = &config.Permissions{}
245	}
246	cfg.Permissions.SkipRequests = yolo
247
248	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
249		return nil, err
250	}
251
252	// Register this project in the centralized projects list.
253	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
254		slog.Warn("Failed to register project", "error", err)
255		// Non-fatal: continue even if registration fails
256	}
257
258	// Connect to DB; this will also run migrations.
259	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
260	if err != nil {
261		return nil, err
262	}
263
264	appInstance, err := app.New(ctx, conn, cfg)
265	if err != nil {
266		slog.Error("Failed to create app instance", "error", err)
267		return nil, err
268	}
269
270	if shouldEnableMetrics(cfg) {
271		event.Init()
272	}
273
274	return appInstance, nil
275}
276
277// setupClientApp sets up a client-based workspace via the server. It
278// auto-starts a detached server process if the socket does not exist.
279func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *proto.Workspace, error) {
280	debug, _ := cmd.Flags().GetBool("debug")
281	yolo, _ := cmd.Flags().GetBool("yolo")
282	dataDir, _ := cmd.Flags().GetString("data-dir")
283	ctx := cmd.Context()
284
285	cwd, err := ResolveCwd(cmd)
286	if err != nil {
287		return nil, nil, err
288	}
289
290	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
291	if err != nil {
292		return nil, nil, err
293	}
294
295	ws, err := c.CreateWorkspace(ctx, proto.Workspace{
296		Path:    cwd,
297		DataDir: dataDir,
298		Debug:   debug,
299		YOLO:    yolo,
300		Env:     os.Environ(),
301	})
302	if err != nil {
303		return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
304	}
305
306	cfg, err := c.GetGlobalConfig(cmd.Context())
307	if err != nil {
308		return nil, nil, fmt.Errorf("failed to get global config: %v", err)
309	}
310
311	if shouldEnableMetrics(cfg) {
312		event.Init()
313	}
314
315	return c, ws, nil
316}
317
318// ensureServer auto-starts a detached server if the socket file does not
319// exist. When connecting to an existing server, it waits for the health
320// endpoint to respond.
321func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
322	switch hostURL.Scheme {
323	case "unix", "npipe":
324		_, err := os.Stat(hostURL.Host)
325		if err != nil && errors.Is(err, fs.ErrNotExist) {
326			if err := startDetachedServer(cmd); err != nil {
327				return err
328			}
329		}
330
331		for range 10 {
332			_, err = os.Stat(hostURL.Host)
333			if err == nil {
334				break
335			}
336			select {
337			case <-cmd.Context().Done():
338				return cmd.Context().Err()
339			case <-time.After(100 * time.Millisecond):
340			}
341		}
342		if err != nil {
343			return fmt.Errorf("failed to initialize crush server: %v", err)
344		}
345	}
346
347	return nil
348}
349
350// waitForHealth polls the server's health endpoint until it responds.
351func waitForHealth(ctx context.Context, c *client.Client) error {
352	var err error
353	for range 10 {
354		err = c.Health(ctx)
355		if err == nil {
356			return nil
357		}
358		select {
359		case <-ctx.Done():
360			return ctx.Err()
361		case <-time.After(100 * time.Millisecond):
362		}
363	}
364	return fmt.Errorf("failed to connect to crush server: %v", err)
365}
366
367// streamEvents forwards SSE events from the client to the TUI program.
368func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
369	defer log.RecoverPanic("app.Subscribe", func() {
370		slog.Info("TUI subscription panic: attempting graceful shutdown")
371		p.Quit()
372	})
373
374	for {
375		select {
376		case <-ctx.Done():
377			slog.Debug("TUI message handler shutting down")
378			return
379		case ev, ok := <-evc:
380			if !ok {
381				slog.Debug("TUI message channel closed")
382				return
383			}
384			p.Send(ev)
385		}
386	}
387}
388
389var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
390
391func startDetachedServer(cmd *cobra.Command) error {
392	exe, err := os.Executable()
393	if err != nil {
394		return fmt.Errorf("failed to get executable path: %v", err)
395	}
396
397	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
398	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
399	if err := os.MkdirAll(chDir, 0o700); err != nil {
400		return fmt.Errorf("failed to create server working directory: %v", err)
401	}
402
403	cmdArgs := []string{"server"}
404	if clientHost != server.DefaultHost() {
405		cmdArgs = append(cmdArgs, "--host", clientHost)
406	}
407
408	c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
409	stdoutPath := filepath.Join(chDir, "stdout.log")
410	stderrPath := filepath.Join(chDir, "stderr.log")
411	detachProcess(c)
412
413	stdout, err := os.Create(stdoutPath)
414	if err != nil {
415		return fmt.Errorf("failed to create stdout log file: %v", err)
416	}
417	defer stdout.Close()
418	c.Stdout = stdout
419
420	stderr, err := os.Create(stderrPath)
421	if err != nil {
422		return fmt.Errorf("failed to create stderr log file: %v", err)
423	}
424	defer stderr.Close()
425	c.Stderr = stderr
426
427	if err := c.Start(); err != nil {
428		return fmt.Errorf("failed to start crush server: %v", err)
429	}
430
431	if err := c.Process.Release(); err != nil {
432		return fmt.Errorf("failed to detach crush server process: %v", err)
433	}
434
435	return nil
436}
437
438func shouldEnableMetrics(cfg *config.Config) bool {
439	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
440		return false
441	}
442	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
443		return false
444	}
445	if cfg.Options.DisableMetrics {
446		return false
447	}
448	return true
449}
450
451func MaybePrependStdin(prompt string) (string, error) {
452	if term.IsTerminal(os.Stdin.Fd()) {
453		return prompt, nil
454	}
455	fi, err := os.Stdin.Stat()
456	if err != nil {
457		return prompt, err
458	}
459	// Check if stdin is a named pipe ( | ) or regular file ( < ).
460	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
461		return prompt, nil
462	}
463	bts, err := io.ReadAll(os.Stdin)
464	if err != nil {
465		return prompt, err
466	}
467	return string(bts) + "\n\n" + prompt, nil
468}
469
470func ResolveCwd(cmd *cobra.Command) (string, error) {
471	cwd, _ := cmd.Flags().GetString("cwd")
472	if cwd != "" {
473		err := os.Chdir(cwd)
474		if err != nil {
475			return "", fmt.Errorf("failed to change directory: %v", err)
476		}
477		return cwd, nil
478	}
479	cwd, err := os.Getwd()
480	if err != nil {
481		return "", fmt.Errorf("failed to get current working directory: %v", err)
482	}
483	return cwd, nil
484}
485
486func createDotCrushDir(dir string) error {
487	if err := os.MkdirAll(dir, 0o700); err != nil {
488		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
489	}
490
491	gitIgnorePath := filepath.Join(dir, ".gitignore")
492	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
493		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
494			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
495		}
496	}
497
498	return nil
499}